From ea0c055620b988d7d28d757671ecc16de12c19aa Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 22 Aug 2026 04:23:59 +0000 Subject: [PATCH] Add repository snapshot release workflow --- .../workflows/publish-repository-snapshot.yml | 111 ++++++++++++++++++ docs/releasing.md | 20 ++++ package.json | 2 +- .../validate-repository-release-selection.mjs | 95 +++++++++++++++ ...date-repository-release-selection.test.mjs | 79 +++++++++++++ 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-repository-snapshot.yml create mode 100644 scripts/validate-repository-release-selection.mjs create mode 100644 scripts/validate-repository-release-selection.test.mjs diff --git a/.github/workflows/publish-repository-snapshot.yml b/.github/workflows/publish-repository-snapshot.yml new file mode 100644 index 0000000..ceb67d3 --- /dev/null +++ b/.github/workflows/publish-repository-snapshot.yml @@ -0,0 +1,111 @@ +name: Publish repository snapshot + +on: + workflow_dispatch: + inputs: + version: + description: Repository snapshot version in YYYY.MM.DD.N format + required: true + type: string + expected_sha: + description: Full 40-character commit SHA expected on main + required: true + type: string + confirmation: + description: "Type: release fancy-kit@ from " + required: true + type: string + +permissions: + contents: read + +concurrency: + group: repository-snapshot-release + cancel-in-progress: false + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + + outputs: + tag: ${{ steps.selection.outputs.tag }} + version: ${{ steps.selection.outputs.version }} + sha: ${{ steps.selection.outputs.sha }} + + steps: + - name: Check out the selected commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate and record the release selection + id: selection + env: + RELEASE_VERSION: ${{ inputs.version }} + EXPECTED_SHA: ${{ inputs.expected_sha }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + selection_json="$(node scripts/validate-repository-release-selection.mjs \ + "$RELEASE_VERSION" \ + "$EXPECTED_SHA" \ + "$GITHUB_SHA" \ + "$GITHUB_REF" \ + "$CONFIRMATION")" + tag="$(printf '%s' "$selection_json" | jq --raw-output '.tag')" + + if git show-ref --verify --quiet "refs/tags/$tag"; then + echo "::error::Tag already exists: $tag" + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "sha=$EXPECTED_SHA" >> "$GITHUB_OUTPUT" + + publish: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: write + + steps: + - name: Prepare release notes + env: + EXPECTED_SHA: ${{ needs.verify.outputs.sha }} + run: | + { + printf 'This immutable release captures the Fancy Kit repository at commit [`%s`](https://github.com/%s/commit/%s).\n\n' \ + "$EXPECTED_SHA" \ + "$GITHUB_REPOSITORY" \ + "$EXPECTED_SHA" + printf 'It represents a repository-wide milestone. The workspace packages and Fancy Kit Harness retain their independent version numbers.\n' + } > "$RUNNER_TEMP/release-notes.md" + + - name: Publish the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.verify.outputs.tag }} + RELEASE_VERSION: ${{ needs.verify.outputs.version }} + EXPECTED_SHA: ${{ needs.verify.outputs.sha }} + run: | + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$EXPECTED_SHA" \ + --title "Fancy Kit repository snapshot $RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --latest=false + + release_url="$(gh release view "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json url \ + --jq '.url')" + + { + printf '### Repository snapshot published\n\n' + printf -- '- Release: [%s](%s)\n' "$TAG" "$release_url" + printf -- '- Commit: `%s`\n' "$EXPECTED_SHA" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/releasing.md b/docs/releasing.md index a80cc2d..d3e10f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -97,6 +97,26 @@ Omit the `octagonal-wheels/dist` command for the scoped packages. The release pu For a coordinated runtime-package release, stage both manifests and the root lockfile, and name both versions in the commit and pull request. Do not dispatch a combined npm publication: the staged workflow continues to select and publish exactly one package per run. +## Repository snapshot releases + +Repository snapshot releases mark reviewed, repository-wide milestones independently of every workspace package and Fancy Kit Harness version. They are normal GitHub Releases with tags in the form `fancy-kit-YYYY.MM.DD.N`, where the final positive integer distinguishes multiple snapshots on one date. They are not marked as the latest release, and they do not publish packages, build Harness assets, or imply that independently versioned components have changed. + +Dispatch `publish-repository-snapshot.yml` from an exact commit on `main`. The workflow validates a real calendar date, a full lowercase commit SHA, the selected Git ref, and an exact confirmation before creating the tag and release. Dispatch publishes the release immediately after verification, so treat starting the workflow as approval to publish the selected repository state and to notify any external services connected to GitHub Releases. + +Review the selected commit and its completed CI evidence, then dispatch the workflow with a date-based version: + +```bash +sha=$(git rev-parse origin/main) +version=YYYY.MM.DD.N +gh workflow run publish-repository-snapshot.yml \ + --ref main \ + -f version="$version" \ + -f expected_sha="$sha" \ + -f confirmation="release fancy-kit@$version from $sha" +``` + +The release captures the GitHub-generated source archive for the exact tagged commit. Create a snapshot only for a meaningful repository milestone; routine merges, package-only version preparation, and CI runs do not require one. + ## GitHub consumer previews Consumer previews are immutable GitHub prereleases for migration testing. They are not npm publications and must not run `npm publish`. diff --git a/package.json b/package.json index b404e8d..9e5bac2 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "check:e2e": "tsc -p test/e2e-obsidian/tsconfig.json", "check:all": "npm run check && npm run check:harness && npm run check:e2e", "check:octagonal-wheels": "npm run check --workspace octagonal-wheels", - "check:release-tools": "node --test scripts/assert-bootstrap-publish.test.mjs scripts/harness-installer.test.mjs scripts/prepare-harness-release.test.mjs scripts/prepare-release.test.mjs scripts/validate-release-selection.test.mjs", + "check:release-tools": "node --test scripts/assert-bootstrap-publish.test.mjs scripts/harness-installer.test.mjs scripts/prepare-harness-release.test.mjs scripts/prepare-release.test.mjs scripts/validate-release-selection.test.mjs scripts/validate-repository-release-selection.test.mjs", "check:workspace": "npm run check:all && npm run check:octagonal-wheels && npm run check:release-tools", "dev:harness": "node apps/obsidian-harness/esbuild.config.mjs", "harness:open": "npm run build:harness && tsx test/e2e-obsidian/scripts/debug-ui.ts", diff --git a/scripts/validate-repository-release-selection.mjs b/scripts/validate-repository-release-selection.mjs new file mode 100644 index 0000000..bb9f89b --- /dev/null +++ b/scripts/validate-repository-release-selection.mjs @@ -0,0 +1,95 @@ +import { fileURLToPath } from "node:url"; + +const versionPattern = /^(\d{4})\.(\d{2})\.(\d{2})\.([1-9]\d*)$/; + +function isLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export function isRepositoryReleaseVersion(version) { + const match = versionPattern.exec(version); + if (!match) return false; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (year === 0 || month < 1 || month > 12) return false; + + const daysInMonth = [ + 31, + isLeapYear(year) ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + return day >= 1 && day <= daysInMonth[month - 1]; +} + +export function validateRepositoryReleaseSelection({ + version, + expectedSha, + actualSha, + ref, + confirmation, +}) { + if (!isRepositoryReleaseVersion(version)) { + throw new Error( + `Invalid repository snapshot version: ${version}; expected YYYY.MM.DD.N with a real calendar date and a positive sequence`, + ); + } + if (!/^[0-9a-f]{40}$/.test(expectedSha)) { + throw new Error("Expected SHA must contain 40 lowercase hexadecimal characters"); + } + if (ref !== "refs/heads/main") { + throw new Error(`Repository snapshot releases must run from main, not ${ref}`); + } + if (expectedSha !== actualSha) { + throw new Error( + `Expected commit ${expectedSha}, but the workflow is running ${actualSha}`, + ); + } + + const requiredConfirmation = `release fancy-kit@${version} from ${expectedSha}`; + if (confirmation !== requiredConfirmation) { + throw new Error(`Confirmation must be exactly: ${requiredConfirmation}`); + } + + return { + tag: `fancy-kit-${version}`, + requiredConfirmation, + }; +} + +function main() { + const [version, expectedSha, actualSha, ref, confirmation] = process.argv.slice(2); + if (process.argv.slice(2).length !== 5) { + throw new Error( + "Usage: node scripts/validate-repository-release-selection.mjs ", + ); + } + + const result = validateRepositoryReleaseSelection({ + version, + expectedSha, + actualSha, + ref, + confirmation, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/validate-repository-release-selection.test.mjs b/scripts/validate-repository-release-selection.test.mjs new file mode 100644 index 0000000..1526c75 --- /dev/null +++ b/scripts/validate-repository-release-selection.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isRepositoryReleaseVersion, + validateRepositoryReleaseSelection, +} from "./validate-repository-release-selection.mjs"; + +const sha = "a".repeat(40); +const base = { + version: "2026.08.22.1", + expectedSha: sha, + actualSha: sha, + ref: "refs/heads/main", + confirmation: `release fancy-kit@2026.08.22.1 from ${sha}`, +}; + +test("accepts a repository snapshot selected from an exact main commit", () => { + assert.deepEqual(validateRepositoryReleaseSelection(base), { + tag: "fancy-kit-2026.08.22.1", + requiredConfirmation: base.confirmation, + }); +}); + +test("accepts valid calendar dates and positive sequences", () => { + for (const version of [ + "2024.02.29.1", + "2026.01.01.2", + "9999.12.31.999", + ]) { + assert.equal(isRepositoryReleaseVersion(version), true, version); + } +}); + +test("rejects malformed versions and impossible calendar dates", () => { + for (const version of [ + "2026.8.22.1", + "2026.08.22", + "2026.08.22.0", + "2026.08.22.01", + "0000.01.01.1", + "2023.02.29.1", + "2026.04.31.1", + "2026.13.01.1", + ]) { + assert.equal(isRepositoryReleaseVersion(version), false, version); + assert.throws( + () => validateRepositoryReleaseSelection({ ...base, version }), + /Invalid repository snapshot version/, + ); + } +}); + +test("requires a full lowercase commit SHA", () => { + for (const expectedSha of ["a".repeat(39), "A".repeat(40), "g".repeat(40)]) { + assert.throws( + () => validateRepositoryReleaseSelection({ ...base, expectedSha }), + /40 lowercase hexadecimal characters/, + ); + } +}); + +test("requires the workflow to run from the selected main commit", () => { + assert.throws( + () => validateRepositoryReleaseSelection({ ...base, ref: "refs/heads/topic" }), + /must run from main/, + ); + assert.throws( + () => validateRepositoryReleaseSelection({ ...base, actualSha: "b".repeat(40) }), + /workflow is running/, + ); +}); + +test("requires the exact release confirmation", () => { + assert.throws( + () => validateRepositoryReleaseSelection({ ...base, confirmation: "release" }), + new RegExp(`Confirmation must be exactly: ${base.confirmation}`), + ); +});