From 1a93e1bd86b0424447e9aa77901b26298eae3121 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 9 Oct 2026 09:02:39 +0000 Subject: [PATCH 1/2] fix: Reconcile publish issue after success --- .github/workflows/publish.yml | 2 +- src/modules/__tests__/publish-workflow.js | 45 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f0bae54..2375445 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -418,7 +418,7 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Update completed targets and remove label - if: ${{ cancelled() || failure() }} + if: ${{ always() }} env: PUBLISH_ARGS: ${{ steps.inputs.outputs.result }} CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }} diff --git a/src/modules/__tests__/publish-workflow.js b/src/modules/__tests__/publish-workflow.js index 4cfcc16..b3f2716 100644 --- a/src/modules/__tests__/publish-workflow.js +++ b/src/modules/__tests__/publish-workflow.js @@ -27,7 +27,13 @@ function getSetTargetsScript() { return section.groups.script.replace(/^ {10}/gm, ""); } -function runSetTargets({ path, repo, version, workspace = "" }) { +function runSetTargets({ + path, + repo, + version, + workspace = "", + targets = ["github"], +}) { const directory = mkdtempSync(join(tmpdir(), "publish-workflow-test-")); temporaryDirectories.push(directory); const output = join(directory, "github-output"); @@ -38,7 +44,7 @@ function runSetTargets({ path, repo, version, workspace = "" }) { ...process.env, CRAFT_PUBLISH_PATH: path, CRAFT_PUBLISH_REPO: repo, - CRAFT_PUBLISH_TARGETS_JSON: '["github"]', + CRAFT_PUBLISH_TARGETS_JSON: JSON.stringify(targets), CRAFT_PUBLISH_VERSION: version, CRAFT_PUBLISH_WORKSPACE: workspace, GITHUB_OUTPUT: output, @@ -116,6 +122,41 @@ test("publishes only on a fresh CI-ready label event", () => { ); }); +test("records completed targets before closing a successful publish issue", () => { + const workflow = getWorkflow(); + const publish = workflow.indexOf("name: Publish using Craft"); + const update = workflow.indexOf( + "name: Update completed targets and remove label" + ); + const close = workflow.indexOf("name: Close on success"); + const updateStep = workflow.slice(update, close); + + expect(update).toBeGreaterThan(publish); + expect(close).toBeGreaterThan(update); + expect(updateStep).toContain("if: ${{ always() }}"); + expect(updateStep).toContain( + "CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }}" + ); + expect(updateStep).toContain( + "run: node .__publish__/src/publish/update-issue.js" + ); + expect(workflow.slice(close)).toContain("if: ${{ success() }}"); +}); + +test("creates a state file when no targets were already published", () => { + const { stateFile, state } = runSetTargets({ + path: ".", + repo: "craft", + version: "2.34.1", + targets: [], + }); + + expect(stateFile).toMatch( + /\.craft-state\/craft\/publish-state-getsentry-craft-c232c383e26f-2\.34\.1\.json$/ + ); + expect(JSON.parse(state)).toEqual({ published: {} }); +}); + test("uses Craft's legacy root state filename", () => { const { stateFile, state } = runSetTargets({ path: ".", From 7bc474adb0b508f58fee6d011b0655c7f6314c83 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 9 Oct 2026 09:20:25 +0000 Subject: [PATCH 2/2] fix: Retain final publish state for issue reconciliation --- .github/workflows/publish.yml | 11 +++++++- src/modules/__tests__/publish-workflow.js | 31 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2375445..e247267 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -354,11 +354,18 @@ jobs: state_file="$state_dir/publish-state-${owner_sanitised}-${repo_sanitised}-${cwd_hash}-${workspace_prefix}${version_component}.json" mkdir -p "$state_dir" printf %s "$payload" > "$state_file" + # Craft writes each completed target to this inode, then unlinks + # the state file on full success. Keep its final contents for the + # issue updater without changing the file Craft reads and writes. + issue_state_file="$state_file.issue" + ln -- "$state_file" "$issue_state_file" echo "state_file=$state_file" >> "$GITHUB_OUTPUT" + echo "issue_state_file=$issue_state_file" >> "$GITHUB_OUTPUT" echo "Wrote state file: $state_file" - uses: docker://getsentry/craft:latest name: Publish using Craft + id: craft-publish with: entrypoint: /bin/bash args: >- @@ -419,9 +426,11 @@ jobs: - name: Update completed targets and remove label if: ${{ always() }} + # Bookkeeping errors must not turn a completed release into a failed publish. + continue-on-error: ${{ steps.craft-publish.outcome == 'success' }} env: PUBLISH_ARGS: ${{ steps.inputs.outputs.result }} - CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }} + CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.issue_state_file }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .__publish__/src/publish/update-issue.js diff --git a/src/modules/__tests__/publish-workflow.js b/src/modules/__tests__/publish-workflow.js index b3f2716..1ae734b 100644 --- a/src/modules/__tests__/publish-workflow.js +++ b/src/modules/__tests__/publish-workflow.js @@ -54,10 +54,10 @@ function runSetTargets({ }); expect(result.status, result.stderr).toBe(0); - const stateFile = readFileSync(output, "utf8") - .trim() - .replace("state_file=", ""); - return { stateFile, state: readFileSync(stateFile, "utf8") }; + const outputs = readFileSync(output, "utf8"); + const stateFile = outputs.match(/^state_file=(.+)$/m)?.[1]; + const issueStateFile = outputs.match(/^issue_state_file=(.+)$/m)?.[1]; + return { stateFile, issueStateFile, state: readFileSync(stateFile, "utf8") }; } afterEach(() => { @@ -129,13 +129,18 @@ test("records completed targets before closing a successful publish issue", () = "name: Update completed targets and remove label" ); const close = workflow.indexOf("name: Close on success"); + const publishStep = workflow.slice(publish, update); const updateStep = workflow.slice(update, close); expect(update).toBeGreaterThan(publish); expect(close).toBeGreaterThan(update); + expect(publishStep).toContain("id: craft-publish"); expect(updateStep).toContain("if: ${{ always() }}"); expect(updateStep).toContain( - "CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }}" + "continue-on-error: ${{ steps.craft-publish.outcome == 'success' }}" + ); + expect(updateStep).toContain( + "CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.issue_state_file }}" ); expect(updateStep).toContain( "run: node .__publish__/src/publish/update-issue.js" @@ -157,6 +162,22 @@ test("creates a state file when no targets were already published", () => { expect(JSON.parse(state)).toEqual({ published: {} }); }); +test("keeps Craft's final state after a successful publish removes its state file", () => { + const { stateFile, issueStateFile } = runSetTargets({ + path: ".", + repo: "craft", + version: "2.34.1", + targets: [], + }); + const finalState = { published: { npm: true, gcs: true } }; + + writeFileSync(stateFile, JSON.stringify(finalState)); + expect(JSON.parse(readFileSync(issueStateFile, "utf8"))).toEqual(finalState); + + rmSync(stateFile); + expect(JSON.parse(readFileSync(issueStateFile, "utf8"))).toEqual(finalState); +}); + test("uses Craft's legacy root state filename", () => { const { stateFile, state } = runSetTargets({ path: ".",