diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f0bae54..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: >- @@ -418,10 +425,12 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Update completed targets and remove label - if: ${{ cancelled() || failure() }} + 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 4cfcc16..1ae734b 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, @@ -48,10 +54,10 @@ function runSetTargets({ path, repo, version, workspace = "" }) { }); 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(() => { @@ -116,6 +122,62 @@ 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 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( + "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" + ); + 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("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: ".",