Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/publish-repository-snapshot.yml
Original file line number Diff line number Diff line change
@@ -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@<version> from <expected-sha>"
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"
20 changes: 20 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions scripts/validate-repository-release-selection.mjs
Original file line number Diff line number Diff line change
@@ -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 <version> <expected-sha> <actual-sha> <ref> <confirmation>",
);
}

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;
}
}
79 changes: 79 additions & 0 deletions scripts/validate-repository-release-selection.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`),
);
});