diff --git a/.agents/skills/commit-and-push/references/litefold-calendar.md b/.agents/skills/commit-and-push/references/litefold-calendar.md index 7e9de1b..49376bd 100644 --- a/.agents/skills/commit-and-push/references/litefold-calendar.md +++ b/.agents/skills/commit-and-push/references/litefold-calendar.md @@ -33,10 +33,12 @@ Resolve the current Node and package-manager selection from repository manifests dependency state from the lockfile, script composition from `package.json`, and hosted behavior from workflows. Do not freeze those values in this skill. -The contributor command index owns local command sequences. Its complete -repository gate is the gate run by hosted CI, but hosted CI may add platform -controls such as dependency review and its exact execution environment. Report -local and hosted results separately. +The contributor command index owns local command sequences and the local versus +hosted browser matrix. The shared gate command checks Chromium, Firefox, and +WebKit locally; hosted CI and publication select Chromium and WebKit. Hosted CI +also adds controls such as dependency review and its exact execution environment. +Report local and hosted results separately, and re-read the command index before +choosing validation. ## Repository-specific safeguards diff --git a/.agents/skills/release-to-npm/SKILL.md b/.agents/skills/release-to-npm/SKILL.md index 123859f..f79aa0d 100644 --- a/.agents/skills/release-to-npm/SKILL.md +++ b/.agents/skills/release-to-npm/SKILL.md @@ -5,9 +5,11 @@ description: Coordinate a maintainer-authorized npm release through repository-o # Release to npm -Coordinate the repository-owned release process without replacing human -approvals or widening credential boundaries. Read-only planning and verification -do not authorize a public mutation. +Coordinate the repository-owned release process within the user's authorized +scope and the configured hosted controls. Read-only planning and verification +do not authorize a public mutation. An explicit request to implement and publish +through named delivery stages does authorize those stages; preserve that scope +instead of requesting the same permission again. ## Load repository authority @@ -37,10 +39,13 @@ workflow runs, the repository's private release record, and fresh read-only registry responses. A local branch, tag, worktree, artifact directory, cached response, or remembered prior run is not publication authority. -Do not infer a version bump, release role, or permission. Confirm the candidate, -the release operator, any required reviewer for the `npm` environment, and the -authenticated npm package maintainer as required by the runbook. One person may -fill multiple roles only when the hosted policy permits it. +Derive the candidate strategy, environment names, and required roles from the +current runbook and hosted state. An authorized nightly start uses unattended +OIDC trusted publication and does not require a manual environment reviewer, npm +token, or local npm login. Nightly leaves `latest` unchanged and verifies it +through public registry reads. Stable publication retains its separately +configured review controls. +One person may fill multiple roles only when the hosted policy permits it. For an authorized start or continuation, create or update the private release record defined by the runbook. Keep it outside the repository in an approved, @@ -51,27 +56,32 @@ conversation, command output, or automation. Treat unavailable, stale, malformed, conflicting, or ambiguous identity evidence as a stop condition. -## Pause at mutation boundaries +## Respect mutation boundaries Immediately before every external mutation: 1. State the exact repository, candidate version, source commit, workflow run, and current public identity that are known at that phase. 2. Explain the expected mutation and its irreversible or protected effects. -3. Obtain explicit authorization for that mutation. +3. Confirm that the user's existing authorization covers that mutation; ask only + when it does not. 4. Follow only the corresponding canonical runbook section. 5. Read back the resulting state and update the private record before advancing. -Separate authorization is required for each applicable preparation dispatch, -pull-request submission, merge, protected publication approval, registry -metadata change, workflow rerun, deployment action, and recovery mutation. A -general request to "release" is not standing approval for later irreversible -steps. - -Never publish locally, collect registry credentials, add a long-lived publishing -token, manufacture a release trigger, push or move a version tag, rewrite a -shared branch, create public release state from an arbitrary ref, or weaken a -hosted protection to make progress. +Identify the authorized preparation, pull-request, merge, publication, registry, +deployment, and recovery stages explicitly. Do not widen an ordinary Git request +into a release or a nightly request into a stable release. Required hosted reviews +still apply even when the user authorizes a complete delivery. A scheduled nightly +uses its reviewed unattended workflow; do not insert an interactive approval into +that workflow or bypass the distinct stable publication controls. + +Never publish locally, collect credentials in conversation or release notes, +manufacture a release trigger, push or move a version tag, rewrite a shared branch, +or create public release state from an arbitrary ref. Changes to hosted controls +or credential policy require an explicitly authorized, reviewed migration; a +failed release is not permission to improvise one. Do not introduce an npm token +or a secondary dist-tag write into the nightly path. The first stable release +owns the future transition of `latest` to stable. ## Follow the selected phase diff --git a/.agents/skills/release-to-npm/agents/openai.yaml b/.agents/skills/release-to-npm/agents/openai.yaml index 4118426..b792a31 100644 --- a/.agents/skills/release-to-npm/agents/openai.yaml +++ b/.agents/skills/release-to-npm/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Release to npm" - short_description: "Coordinate protected npm releases" - default_prompt: "Use $release-to-npm to classify the requested release phase, load the repository runbook, and pause before every external mutation." + short_description: "Coordinate and verify npm release stages" + default_prompt: "Use $release-to-npm to load the current release runbook, complete the authorized release stages, and verify exact public package and site identities." diff --git a/.agents/skills/release-to-npm/references/litefold-calendar.md b/.agents/skills/release-to-npm/references/litefold-calendar.md index d4ded56..f425066 100644 --- a/.agents/skills/release-to-npm/references/litefold-calendar.md +++ b/.agents/skills/release-to-npm/references/litefold-calendar.md @@ -17,12 +17,11 @@ Stop before a hosted mutation when any normalized identity differs. | Need | Read | | --- | --- | | Supported release policy and invariants | [Release policy](../../../../docs/releasing.md) | -| Normal operator procedure and private release record | [Alpha release operations](../../../../docs/release-operations.md) | +| Normal operator procedure and private release record | [Nightly release operations](../../../../docs/release-operations.md) | | Hosted controls, unsupported transitions, exceptional actions, and recovery | [Release administration](../../../../docs/release-administration.md) | | Artifact, installed-package, registry, signature, and provenance evidence | [Package verification](../../../../docs/package-verification.md) | | Release-linked example deployment and rollback | [Static example deployment](../../../../docs/example-deployment.md) | -| Executable preparation state machine | [Prepare alpha release workflow](../../../../.github/workflows/prepare-alpha.yml) | -| Executable publication state machine | [Publish alpha release workflow](../../../../.github/workflows/publish-alpha.yml) | +| Executable nightly snapshot and publication state machine | [Publish nightly workflow](../../../../.github/workflows/publish-nightly.yml) | Read the current phase in its canonical owner immediately before acting. The documents and workflows override remembered behavior and this routing file. @@ -39,3 +38,11 @@ Use only the supported transition described by current release administration. If a requested state has no reviewed procedure, stop and require a separately designed and approved one. Describe recovery by the observed state and required identity evidence, without relying on release chronology. + +Nightly publication snapshots reviewed `main` without a daily metadata commit. +Its workflow owns generated versions, exact artifact retention, OIDC publication +under `nightly`, and repeat-run recovery. It uses no npm token and verifies that +`latest` remains unchanged. Preserve historical alpha state. Stable +publication is a separate procedure and must not be inferred from a nightly +request. The [delivery checklist](../../../../docs/change-delivery.md) connects +ordinary source changes with final package and Pages verification. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472e752..f5e71ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,13 @@ jobs: run: npm ci --ignore-scripts - name: Install pinned Playwright browsers - run: npx playwright install --with-deps chromium firefox webkit + run: npx playwright install --with-deps chromium webkit - - name: Run the complete local quality gate + - name: Run the CI quality gate run: npm run check - - name: Upload browser diagnostics on failure - if: failure() + - name: Upload browser results and failure diagnostics + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: browser-diagnostics-${{ github.run_id }} diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index bdf180b..2ae6bc5 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -4,7 +4,7 @@ on: workflow_run: workflows: - CI - - Publish npm alpha + - Publish npm nightly types: - completed branches: @@ -29,7 +29,7 @@ jobs: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch') && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository }} @@ -38,6 +38,7 @@ jobs: permissions: contents: read + actions: read outputs: channel: ${{ steps.identity.outputs.channel }} @@ -62,6 +63,8 @@ jobs: LFC_UPSTREAM_WORKFLOW: ${{ github.event.workflow_run.name }} LFC_UPSTREAM_WORKFLOW_PATH: ${{ github.event.workflow_run.path }} LFC_WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }} + LFC_UPSTREAM_RUN_ID: ${{ github.event.workflow_run.id }} + LFC_UPSTREAM_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} run: | set -euo pipefail @@ -77,16 +80,18 @@ jobs: "${upstream_workflow_path}" == ".github/workflows/ci.yml" && "${LFC_UPSTREAM_EVENT}" == "push" ]]; then channel="main" - elif [[ "${LFC_UPSTREAM_WORKFLOW}" == "Publish npm alpha" && - "${upstream_workflow_path}" == ".github/workflows/publish-alpha.yml" && - "${LFC_UPSTREAM_EVENT}" == "push" ]]; then - parent_version="$(git show HEAD^1:package.json | jq --exit-status --raw-output '.version')" - if [[ "${version}" == "${parent_version}" ]]; then - eligible=false - channel="" - else - channel="release" - fi + elif [[ "${LFC_UPSTREAM_WORKFLOW}" == "Publish npm nightly" && + "${upstream_workflow_path}" == ".github/workflows/publish-nightly.yml" && + ( "${LFC_UPSTREAM_EVENT}" == "schedule" || "${LFC_UPSTREAM_EVENT}" == "workflow_dispatch" ) ]]; then + channel="release" + base_version="${version}" + created_at="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${LFC_UPSTREAM_RUN_ID}" --jq '.created_at')" + printf 'LFC_BROWSER_TARGET_DATE=%s\n' "${created_at:0:10}" >> "${GITHUB_ENV}" + stamp="$(date -u --date="${created_at}" +%Y%m%d%H%M%S)" + version="${base_version%-nightly.0}-nightly.${stamp}.${LFC_UPSTREAM_RUN_ID}" + run_jobs="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${LFC_UPSTREAM_RUN_ID}/attempts/${LFC_UPSTREAM_RUN_ATTEMPT}/jobs?per_page=100" --jq '.jobs[] | select(.name == "Publish the verified GitHub prerelease") | .conclusion')" + if [[ "${run_jobs}" == "skipped" ]]; then eligible=false; channel=""; + elif [[ "${run_jobs}" != "success" ]]; then echo 'Publisher finalization is unproven.' >&2; exit 1; fi else echo "Unexpected upstream workflow identity: ${LFC_UPSTREAM_WORKFLOW} (${LFC_UPSTREAM_WORKFLOW_PATH})" >&2 exit 1 @@ -95,8 +100,8 @@ jobs: if [[ "${eligible}" == "true" && "${channel}" == "release" ]]; then release_ref="v${version}" channel="release" - if [[ ! "${version}" =~ ^0\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "A changed release version must be an exact supported alpha." >&2 + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{14}\.[0-9]+$ ]]; then + echo "A changed release version must be an exact generated nightly." >&2 exit 1 fi test "$(git rev-parse --verify "${release_ref}^{commit}")" = "${source_commit}" @@ -105,6 +110,18 @@ jobs: .draft == false and .prerelease == true and .immutable == true and .tag_name == $tag and .target_commitish == $commit ' <<< "${release}" > /dev/null + gh release download "${release_ref}" --repo "${GITHUB_REPOSITORY}" --pattern package-verification.json --dir "${RUNNER_TEMP}/nightly-receipt" + receipt="${RUNNER_TEMP}/nightly-receipt/package-verification.json" + expected_digest="$(jq -er '.assets[] | select(.name == "package-verification.json") | .digest | sub("^sha256:"; "")' <<< "${release}")" + test "$(sha256sum "${receipt}" | cut -d ' ' -f 1)" = "${expected_digest}" + jq --exit-status --arg sha "${source_commit}" --arg version "${version}" --arg base "${base_version}" --arg run "${LFC_UPSTREAM_RUN_ID}" ' + .schemaVersion == 2 and .name == "@tryagaindev/litefold-calendar" and + .sourceCommit == $sha and .version == $version and .sourceVersion == $base and + .sourceTreeDirty == false and .manifestTransform == "version-only" and + .nightly.runId == $run and .nightly.sourceCommit == $sha and .nightly.version == $version and + .nightly.workflow == ".github/workflows/publish-nightly.yml" + ' "${receipt}" > /dev/null + printf 'LFC_NIGHTLY_RECEIPT=%s\n' "${receipt}" >> "${GITHUB_ENV}" fi printf 'eligible=%s\n' "${eligible}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/prepare-alpha.yml b/.github/workflows/prepare-alpha.yml deleted file mode 100644 index 358297f..0000000 --- a/.github/workflows/prepare-alpha.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: Prepare alpha release - -on: - workflow_dispatch: - inputs: - bump: - description: Version change to prepare - required: true - default: Continue alpha - type: choice - options: - - Continue alpha - - Next patch alpha - - Next minor alpha - -permissions: - contents: read - -concurrency: - group: prepare-alpha-${{ github.repository }} - cancel-in-progress: false - -env: - LFC_NODE_VERSION: "24.19.0" - LFC_NPM_VERSION: "12.0.2" - -jobs: - prepare: - name: Prepare release files without write authority - if: ${{ github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - - permissions: - contents: read - - outputs: - branch: ${{ steps.release.outputs.branch }} - version: ${{ steps.release.outputs.version }} - - steps: - - name: Check out the trusted default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.sha }} - - - name: Set up exact Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ env.LFC_NODE_VERSION }} - package-manager-cache: false - - - name: Set up exact npm - working-directory: ${{ runner.temp }} - run: npm install --global "npm@${LFC_NPM_VERSION}" --ignore-scripts - - - name: Install locked release tooling - run: npm ci --ignore-scripts - - - name: Prepare and validate release state - id: release - env: - LFC_BUMP: ${{ inputs.bump }} - run: | - set -euo pipefail - - git fetch --no-tags origin main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" - - case "${LFC_BUMP}" in - "Continue alpha") bump=prerelease ;; - "Next patch alpha") bump=prepatch ;; - "Next minor alpha") bump=preminor ;; - *) echo "Unsupported release bump choice." >&2; exit 1 ;; - esac - - npm run release:prepare -- --bump "${bump}" --json - - version="$(node --input-type=module -e 'import manifest from "./package.json" with { type: "json" }; process.stdout.write(manifest.version);')" - branch="release/v${version}" - - mapfile -t changed < <(git diff --name-only | LC_ALL=C sort) - expected=(CHANGELOG.md package-lock.json package.json) - diff -u \ - <(printf '%s\n' "${expected[@]}") \ - <(printf '%s\n' "${changed[@]}") - - if git ls-remote --exit-code --heads origin "refs/heads/${branch}" > /dev/null 2>&1; then - echo "Release preparation branch ${branch} already exists." >&2 - exit 1 - fi - - npm run release:verify \ - -- --version "${version}" --tag-state absent - - printf 'version=%s\n' "${version}" >> "${GITHUB_OUTPUT}" - printf 'branch=%s\n' "${branch}" >> "${GITHUB_OUTPUT}" - - - name: Upload only the prepared release files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: alpha-preparation-${{ github.run_id }}-${{ github.run_attempt }} - if-no-files-found: error - overwrite: false - path: | - CHANGELOG.md - package-lock.json - package.json - retention-days: 1 - - create-branch: - name: Create the reviewed release branch - needs: prepare - runs-on: ubuntu-latest - timeout-minutes: 5 - - permissions: - actions: read - contents: write - - steps: - - name: Download the prepared release files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: alpha-preparation-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/alpha-preparation - - - name: Validate and commit without repository credentials - env: - LFC_BRANCH: ${{ needs.prepare.outputs.branch }} - LFC_PREPARATION: ${{ runner.temp }}/alpha-preparation - LFC_SOURCE_COMMIT: ${{ github.sha }} - LFC_VERSION: ${{ needs.prepare.outputs.version }} - run: | - set -euo pipefail - - expected=(CHANGELOG.md package-lock.json package.json) - mapfile -t actual < <( - find "${LFC_PREPARATION}" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | - LC_ALL=C sort - ) - diff -u \ - <(printf '%s\n' "${expected[@]}") \ - <(printf '%s\n' "${actual[@]}") - test -z "$(find "${LFC_PREPARATION}" -mindepth 1 ! -type f -print -quit)" - [[ "${LFC_SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]] - [[ "${LFC_VERSION}" =~ ^0\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]] - test "${LFC_BRANCH}" = "release/v${LFC_VERSION}" - test "$(jq --exit-status --raw-output .version "${LFC_PREPARATION}/package.json")" = "${LFC_VERSION}" - test "$(jq --exit-status --raw-output .version "${LFC_PREPARATION}/package-lock.json")" = "${LFC_VERSION}" - test "$(jq --exit-status --raw-output '.packages[""].version' "${LFC_PREPARATION}/package-lock.json")" = "${LFC_VERSION}" - - canonical_origin="https://github.com/${GITHUB_REPOSITORY}.git" - if git -c credential.helper= ls-remote --exit-code --heads \ - "${canonical_origin}" "refs/heads/${LFC_BRANCH}" > /dev/null 2>&1; then - echo "Release preparation branch ${LFC_BRANCH} already exists." >&2 - exit 1 - fi - - repository="${RUNNER_TEMP}/prepared-repository" - mkdir -- "${repository}" - git -C "${repository}" init - git -C "${repository}" remote add origin "${canonical_origin}" - git -C "${repository}" -c credential.helper= fetch --no-tags --depth=1 \ - origin "${LFC_SOURCE_COMMIT}" - git -C "${repository}" switch --detach FETCH_HEAD - test "$(git -C "${repository}" rev-parse --verify HEAD^{commit})" = "${LFC_SOURCE_COMMIT}" - cp -- "${LFC_PREPARATION}/CHANGELOG.md" \ - "${LFC_PREPARATION}/package-lock.json" \ - "${LFC_PREPARATION}/package.json" \ - "${repository}" - mapfile -t changed < <(git -C "${repository}" diff --name-only | LC_ALL=C sort) - diff -u \ - <(printf '%s\n' "${expected[@]}") \ - <(printf '%s\n' "${changed[@]}") - - git -C "${repository}" config user.name "github-actions[bot]" - git -C "${repository}" config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git -C "${repository}" switch --create "${LFC_BRANCH}" - git -C "${repository}" add -- CHANGELOG.md package-lock.json package.json - git -C "${repository}" commit -m "chore: prepare ${LFC_VERSION} release" - - - name: Push only the prepared branch without force - env: - GH_TOKEN: ${{ github.token }} - LFC_BRANCH: ${{ needs.prepare.outputs.branch }} - LFC_VERSION: ${{ needs.prepare.outputs.version }} - working-directory: ${{ runner.temp }}/prepared-repository - run: | - set -euo pipefail - - gh auth setup-git - git push origin "HEAD:refs/heads/${LFC_BRANCH}" - compare_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/main...${LFC_BRANCH}?expand=1" - { - printf '## Alpha %s is ready for review\n\n' "${LFC_VERSION}" - printf '[Open the prefilled release pull request](%s) and merge it after CI succeeds.\n' "${compare_url}" - } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-nightly.yml similarity index 70% rename from .github/workflows/publish-alpha.yml rename to .github/workflows/publish-nightly.yml index 4445f48..997431b 100644 --- a/.github/workflows/publish-alpha.yml +++ b/.github/workflows/publish-nightly.yml @@ -1,20 +1,20 @@ -name: Publish npm alpha +name: Publish npm nightly on: - push: - branches: - - main - paths: - - .github/workflows/publish-alpha.yml - - CHANGELOG.md - - package-lock.json - - package.json + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + inputs: + expected-source: + description: Optional exact current main commit assertion + required: false + type: string permissions: contents: read concurrency: - group: npm-alpha-${{ github.repository }} + group: npm-nightly-${{ github.repository }} queue: max cancel-in-progress: false @@ -31,62 +31,55 @@ env: jobs: classify: - name: Detect a generated release candidate + name: Select the exact green main snapshot + if: ${{ github.repository == 'tryagaindev/litefold-calendar' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 5 - permissions: + actions: read contents: read - outputs: candidate-version: ${{ steps.candidate.outputs.candidate-version }} + base-version: ${{ steps.candidate.outputs.base-version }} + created-at: ${{ steps.candidate.outputs.created-at }} eligible: ${{ steps.candidate.outputs.eligible }} - parent-version: ${{ steps.candidate.outputs.parent-version }} - steps: - - name: Check out the exact pushed commit and its first parent + - name: Check out the exact workflow source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 2 + fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - - name: Classify the exact pushed commit + - name: Classify the exact nightly source id: candidate env: - LFC_EVENT_NAME: ${{ github.event_name }} - LFC_SOURCE_COMMIT: ${{ github.sha }} - LFC_WORKFLOW_COMMIT: ${{ github.workflow_sha }} - LFC_WORKFLOW_REF: ${{ github.ref }} + GH_TOKEN: ${{ github.token }} + LFC_EXPECTED_SOURCE: ${{ inputs.expected-source }} run: | set -euo pipefail - - if [[ "${LFC_EVENT_NAME}" != "push" || - "${LFC_WORKFLOW_REF}" != "refs/heads/main" || - ! "${LFC_SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ || - ! "${LFC_WORKFLOW_COMMIT}" =~ ^[0-9a-f]{40}$ || - "${LFC_WORKFLOW_COMMIT}" != "${LFC_SOURCE_COMMIT}" ]]; then - echo "The release source must be the exact reviewed workflow commit pushed to main." >&2 - exit 1 + test "${GITHUB_WORKFLOW_SHA}" = "${GITHUB_SHA}" + test "${GITHUB_REF}" = "refs/heads/main" + [[ "${GITHUB_EVENT_NAME}" == "schedule" || "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] + [[ -z "${LFC_EXPECTED_SOURCE}" || "${LFC_EXPECTED_SOURCE}" == "${GITHUB_SHA}" ]] + gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&event=push&per_page=100" > ci.json + jq --exit-status --arg sha "${GITHUB_SHA}" --arg repository "${GITHUB_REPOSITORY}" ' + [.workflow_runs[] | select(.head_sha == $sha and .head_branch == "main" and + .head_repository.full_name == $repository)] | sort_by(.run_number) | last | + .conclusion == "success" + ' ci.json > /dev/null + created_at="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at')" + base_version="$(jq --exit-status --raw-output '.version' package.json)" + [[ "${base_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.0$ ]] + stamp="$(date -u --date="${created_at}" +%Y%m%d%H%M%S)" + version="${base_version%-nightly.0}-nightly.${stamp}.${GITHUB_RUN_ID}" + current_main="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if [[ "${current_main}" != "${GITHUB_SHA}" ]]; then + curl --fail --silent --show-error "https://registry.npmjs.org/@tryagaindev%2Flitefold-calendar/${version}" > "${RUNNER_TEMP}/existing-candidate.json" + jq --exit-status --arg version "${version}" '.name == "@tryagaindev/litefold-calendar" and .version == $version' "${RUNNER_TEMP}/existing-candidate.json" > /dev/null fi - test "$(git rev-parse --verify HEAD^{commit})" = "${LFC_SOURCE_COMMIT}" - parent_commit="$(git rev-parse --verify HEAD^1)" - candidate_manifest="$(&2 - exit 1 - fi - eligible=true - fi - printf 'candidate-version=%s\n' "${candidate_version}" >> "${GITHUB_OUTPUT}" - printf 'eligible=%s\n' "${eligible}" >> "${GITHUB_OUTPUT}" - printf 'parent-version=%s\n' "${parent_version}" >> "${GITHUB_OUTPUT}" + printf 'candidate-version=%s\nbase-version=%s\ncreated-at=%s\neligible=true\n' \ + "${version}" "${base_version}" "${created_at}" >> "${GITHUB_OUTPUT}" + rm -- ci.json verify: name: Verify exact source and create release evidence @@ -94,7 +87,7 @@ jobs: if: >- ${{ needs.classify.outputs.eligible == 'true' && - github.event_name == 'push' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.workflow_sha == github.sha }} @@ -103,14 +96,19 @@ jobs: permissions: contents: read + actions: read outputs: + eligible: ${{ steps.registry-state.outputs.eligible }} + stable-exists: ${{ steps.registry-state.outputs.stable-exists }} + base-version: ${{ needs.classify.outputs.base-version }} asset-digests: ${{ steps.evidence.outputs.asset-digests }} bundle-artifact: ${{ steps.identity.outputs.bundle-artifact }} bundle-name: ${{ steps.identity.outputs.bundle-name }} notes-artifact: ${{ steps.identity.outputs.notes-artifact }} notes-sha256: ${{ steps.evidence.outputs.notes-sha256 }} - registry-alpha: ${{ steps.registry-state.outputs.registry-alpha }} + registry-predecessor: ${{ steps.registry-state.outputs.registry-predecessor }} + registry-latest: ${{ steps.registry-state.outputs.registry-latest }} registry-state-sha256: ${{ steps.registry-state.outputs.registry-state-sha256 }} source-commit: ${{ steps.identity.outputs.source-commit }} tag: ${{ steps.identity.outputs.tag }} @@ -137,147 +135,128 @@ jobs: - name: Install locked development tooling run: npm ci --ignore-scripts - - name: Validate the immutable first-parent release diff + - name: Derive and verify immutable nightly artifact identity id: identity env: - LFC_CLASSIFIED_VERSION: ${{ needs.classify.outputs.candidate-version }} - LFC_PARENT_VERSION: ${{ needs.classify.outputs.parent-version }} - LFC_SOURCE_COMMIT: ${{ github.sha }} + LFC_VERSION: ${{ needs.classify.outputs.candidate-version }} + LFC_CREATED_AT: ${{ needs.classify.outputs.created-at }} run: | set -euo pipefail - - source_commit="$(git rev-parse --verify HEAD^{commit})" - test "${source_commit}" = "${LFC_SOURCE_COMMIT}" - git fetch --force --no-tags origin \ - refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse --verify refs/remotes/origin/main^{commit})" = "${source_commit}" - - version="$(node --input-type=module -e 'import manifest from "./package.json" with { type: "json" }; process.stdout.write(manifest.version);')" - test "${version}" = "${LFC_CLASSIFIED_VERSION}" - parent_version="$(git show HEAD^1:package.json | jq -r '.version // empty')" - test "${parent_version}" = "${LFC_PARENT_VERSION}" - test "${parent_version}" != "${version}" - tag="v${version}" - - mapfile -t changed_files < <( - git diff --name-only HEAD^1 HEAD | LC_ALL=C sort -u - ) - expected_files=(CHANGELOG.md package-lock.json package.json) - diff -u \ - <(printf '%s\n' "${expected_files[@]}") \ - <(printf '%s\n' "${changed_files[@]}") - - npm run release:verify -- \ - --version "${version}" \ - --commit "${source_commit}" \ - --tag-state either \ - --require-clean - - printf 'bundle-artifact=npm-release-bundle-%s-%s-%s\n' \ - "${tag}" "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" - printf 'bundle-name=tryagaindev-litefold-calendar-%s\n' "${version}" >> "${GITHUB_OUTPUT}" - printf 'notes-artifact=release-notes-%s-%s-%s\n' \ - "${tag}" "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" - printf 'source-commit=%s\n' "${source_commit}" >> "${GITHUB_OUTPUT}" - printf 'tag=%s\n' "${tag}" >> "${GITHUB_OUTPUT}" - printf 'version=%s\n' "${version}" >> "${GITHUB_OUTPUT}" - - - name: Validate live registry channel before release work + test "$(git rev-parse --verify HEAD^{commit})" = "${GITHUB_SHA}" + npm run nightly:plan -- --run-id "${GITHUB_RUN_ID}" --created-at "${LFC_CREATED_AT}" \ + --event "${GITHUB_EVENT_NAME}" --source-sha "${GITHUB_SHA}" --output "${RUNNER_TEMP}/nightly-plan.json" + test "$(jq -r .version "${RUNNER_TEMP}/nightly-plan.json")" = "${LFC_VERSION}" + printf 'LFC_BROWSER_TARGET_DATE=%s\n' "${LFC_CREATED_AT:0:10}" >> "${GITHUB_ENV}" + tag="v${LFC_VERSION}" + printf 'bundle-artifact=npm-release-bundle-%s-%s-%s\n' "${tag}" "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" + printf 'bundle-name=tryagaindev-litefold-calendar-%s\n' "${LFC_VERSION}" >> "${GITHUB_OUTPUT}" + printf 'notes-artifact=release-notes-%s-%s-%s\n' "${tag}" "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" + printf 'source-commit=%s\ntag=%s\nversion=%s\n' "${GITHUB_SHA}" "${tag}" "${LFC_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Validate live registry and previous completed nightly id: registry-state env: + GH_TOKEN: ${{ github.token }} LFC_VERSION: ${{ steps.identity.outputs.version }} run: | set -euo pipefail - registry_raw="${RUNNER_TEMP}/registry.raw.json" - registry_json="${RUNNER_TEMP}/registry.json" - registry_error="${RUNNER_TEMP}/registry.err" - set +e - npm view @tryagaindev/litefold-calendar versions dist-tags --json \ - > "${registry_raw}" 2> "${registry_error}" - registry_status=$? - set -e - if [[ "${registry_status}" -eq 0 ]]; then - node --input-type=module --eval "${LFC_NORMALIZE_NPM_VIEW_JSON}" \ - "${registry_raw}" "${registry_json}" - metadata="$(<"${registry_json}")" - registry_state="$( - jq --compact-output --sort-keys ' - { - "dist-tags": ."dist-tags", - versions: (.versions | if type == "string" then [.] else . end | sort) - } - ' "${registry_json}" - )" - registry_alpha="$(jq --raw-output '."dist-tags".alpha // empty' "${registry_json}")" - elif grep -Eq 'E404|404 Not Found' "${registry_error}"; then - echo "The npm package must already exist before the normal alpha release workflow can run." >&2 - exit 1 - else - cat "${registry_error}" >&2 - exit "${registry_status}" - fi - export LFC_REGISTRY_METADATA="${metadata}" - # LFC_PRERELEASE_CHANNEL_POLICY_START + npm view @tryagaindev/litefold-calendar versions dist-tags --json --registry https://registry.npmjs.org/ > "${RUNNER_TEMP}/registry.raw.json" + node --input-type=module --eval "${LFC_NORMALIZE_NPM_VIEW_JSON}" "${RUNNER_TEMP}/registry.raw.json" "${RUNNER_TEMP}/registry.json" node --input-type=module <<'NODE' - import semver from "semver"; - - const candidate = process.env.LFC_VERSION; - const metadata = JSON.parse(process.env.LFC_REGISTRY_METADATA); - const versions = typeof metadata.versions === "string" - ? [metadata.versions] - : metadata.versions; - const tags = metadata["dist-tags"]; - if (!Array.isArray(versions) || tags === null || typeof tags !== "object" || - Array.isArray(tags)) { - throw new Error("npm returned malformed release metadata."); - } - const supportedAlpha = (value) => typeof value === "string" && - /^0\.[0-9]+\.[0-9]+-alpha\.[0-9]+$/u.test(value) && - semver.valid(value) === value; - for (const version of versions) { - if (typeof version !== "string" || semver.valid(version) === null) { - throw new Error("npm returned a malformed published version."); - } - if (version !== candidate && semver.gte(version, candidate)) { - throw new Error(`Published version ${version} is not older than ${candidate}.`); - } - } - if (versions.length === 0) { - throw new Error("The normal alpha release workflow requires an existing npm package."); - } - for (const name of ["alpha", "latest"]) { - const value = tags[name]; - if (!supportedAlpha(value) || !versions.includes(value)) { - throw new Error(`npm ${name} must select a published supported alpha; found ${String(value)}.`); - } - if (value !== candidate && semver.gte(value, candidate)) { - throw new Error(`npm ${name} ${value} cannot move to ${candidate}.`); - } - } - const publicationInProgress = versions.includes(candidate) && - tags.alpha === candidate && tags.latest !== candidate; - if (tags.alpha !== tags.latest && !publicationInProgress) { - throw new Error( - `npm alpha and latest must match before a release; found alpha=${tags.alpha}, latest=${tags.latest}.` - ); - } + import { readFileSync, appendFileSync } from "node:fs"; + import { readNightlyPlan, validateNightlyRegistryState } from "./scripts/lib/nightly-release.mjs"; + const metadata = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/registry.json`, "utf8")); + const state = validateNightlyRegistryState(metadata, await readNightlyPlan(`${process.env.RUNNER_TEMP}/nightly-plan.json`)); + appendFileSync(process.env.GITHUB_OUTPUT, `registry-predecessor=${state.predecessor}\nregistry-latest=${state.latest}\nstable-exists=${state.stableExists}\n`); NODE - # LFC_PRERELEASE_CHANNEL_POLICY_END + predecessor="$(jq -r '."dist-tags".nightly // ."dist-tags".alpha' "${RUNNER_TEMP}/registry.json")" + eligible=true + if [[ "${predecessor}" == *-nightly.* && "${predecessor}" != "${LFC_VERSION}" ]]; then + previous="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${predecessor}")" + jq --exit-status --arg tag "v${predecessor}" '.draft == false and .prerelease == true and .immutable == true and .tag_name == $tag' <<< "${previous}" > /dev/null + previous_sha="$(jq -er '.target_commitish | select(test("^[0-9a-f]{40}$"))' <<< "${previous}")" + curl --fail --silent --show-error "https://tryagaindev.github.io/litefold-calendar/releases/${predecessor}/examples/metadata.json" > "${RUNNER_TEMP}/previous-pages.json" + jq --exit-status --arg version "${predecessor}" --arg sha "${previous_sha}" '.version == $version and .commit == $sha and .channel == "release"' "${RUNNER_TEMP}/previous-pages.json" > /dev/null + if [[ "${previous_sha}" == "${GITHUB_SHA}" ]]; then eligible=false; fi + fi + registry_state="$(jq --compact-output --sort-keys '{"dist-tags": ."dist-tags", versions: (.versions | if type == "string" then [.] else . end | sort)}' "${RUNNER_TEMP}/registry.json")" + printf 'registry-state-sha256=%s\neligible=%s\n' "$(printf '%s' "${registry_state}" | sha256sum | cut -d ' ' -f 1)" "${eligible}" >> "${GITHUB_OUTPUT}" + if [[ "${eligible}" == "false" ]]; then echo 'No new source since the last completely verified nightly.' >> "${GITHUB_STEP_SUMMARY}"; fi - registry_state_sha256="$( - printf '%s' "${registry_state}" | sha256sum | cut -d ' ' -f 1 - )" - printf 'registry-alpha=%s\n' "${registry_alpha}" >> "${GITHUB_OUTPUT}" - printf 'registry-state-sha256=%s\n' "${registry_state_sha256}" >> "${GITHUB_OUTPUT}" + - name: Restore earlier verified bytes before considering a rebuild + id: recovery + if: ${{ steps.registry-state.outputs.eligible == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + LFC_VERSION: ${{ steps.identity.outputs.version }} + LFC_BUNDLE_DIRECTORY: .artifacts/${{ steps.identity.outputs.bundle-name }} + run: | + set -euo pipefail + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" > "${RUNNER_TEMP}/prior-artifacts.json" + gh api graphql -f query='query($owner:String!,$name:String!,$ref:String!,$tag:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){id} release(tagName:$tag){id}}}' \ + -F owner="${GITHUB_REPOSITORY_OWNER}" -F name="${GITHUB_REPOSITORY#*/}" \ + -F ref="refs/tags/v${LFC_VERSION}" -F tag="v${LFC_VERSION}" > "${RUNNER_TEMP}/prior-public-state.json" + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; + import { readNightlyPlan, selectNightlyRecoveryArtifacts } from "./scripts/lib/nightly-release.mjs"; + const root = process.env.RUNNER_TEMP; + const plan = await readNightlyPlan(`${root}/nightly-plan.json`); + const pages = JSON.parse(readFileSync(`${root}/prior-artifacts.json`, "utf8")); + const repository = JSON.parse(readFileSync(`${root}/prior-public-state.json`, "utf8"))?.data?.repository; + if (!Array.isArray(pages) || !repository) throw new Error("Nightly recovery remote evidence is unavailable."); + const registry = JSON.parse(readFileSync(`${root}/registry.json`, "utf8")); + const versions = typeof registry.versions === "string" ? [registry.versions] : registry.versions; + const selected = selectNightlyRecoveryArtifacts({ artifacts: pages.flatMap((page) => page.artifacts), + plan, attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + hasPublicState: repository.ref !== null || repository.release !== null || versions.includes(plan.version) }); + writeFileSync(`${root}/nightly-recovery.json`, JSON.stringify(selected)); + appendFileSync(process.env.GITHUB_OUTPUT, `restored=${selected !== null}\n`); + NODE + if [[ "$(jq -r 'type' "${RUNNER_TEMP}/nightly-recovery.json")" == "null" ]]; then exit 0; fi + for kind in bundle notes; do + id="$(jq -er --arg kind "${kind}" '.[$kind].id' "${RUNNER_TEMP}/nightly-recovery.json")" + digest="$(jq -er --arg kind "${kind}" '.[$kind].digest | sub("^sha256:"; "")' "${RUNNER_TEMP}/nightly-recovery.json")" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}/zip" > "${RUNNER_TEMP}/nightly-${kind}.zip" + test "$(sha256sum "${RUNNER_TEMP}/nightly-${kind}.zip" | cut -d ' ' -f 1)" = "${digest}" + done + python3 <<'PYTHON' + import os + from pathlib import Path + from zipfile import ZipFile + version = os.environ["LFC_VERSION"] + root = Path(os.environ["RUNNER_TEMP"]) + outputs = {"bundle": (Path(os.environ["LFC_BUNDLE_DIRECTORY"]), + {"LICENSE", "SHA256SUMS", "package-verification.json", "sbom.spdx.json", f"tryagaindev-litefold-calendar-{version}.tgz"}), + "notes": (root / "release-notes", {"release-notes.md"})} + for kind, (destination, expected) in outputs.items(): + with ZipFile(root / f"nightly-{kind}.zip") as archive: + members = archive.infolist() + if len(members) != len(expected) or {entry.filename for entry in members} != expected or sum(entry.file_size for entry in members) > 64 * 1024 * 1024: + raise ValueError("Retained artifact does not contain the exact bounded evidence files.") + destination.mkdir(parents=True, exist_ok=False) + for entry in members: + with (destination / entry.filename).open("xb") as output: + output.write(archive.read(entry)) + PYTHON + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { readNightlyPlan, verifyRestoredNightlyBundle } from "./scripts/lib/nightly-release.mjs"; + await verifyRestoredNightlyBundle(process.env.LFC_BUNDLE_DIRECTORY, + await readNightlyPlan(`${process.env.RUNNER_TEMP}/nightly-plan.json`), + JSON.parse(readFileSync("package.json", "utf8"))); + NODE + echo 'Reusing the original digest-verified package bundle and notes; no rebuild.' >> "${GITHUB_STEP_SUMMARY}" - name: Install pinned Playwright browsers - run: npx playwright install --with-deps chromium firefox webkit + if: ${{ steps.registry-state.outputs.eligible == 'true' && steps.recovery.outputs.restored != 'true' }} + run: npx playwright install --with-deps chromium webkit - name: Run the complete release quality gate + if: ${{ steps.registry-state.outputs.eligible == 'true' && steps.recovery.outputs.restored != 'true' }} run: npm run check - name: Upload browser diagnostics on failure - if: failure() + if: failure() && steps.registry-state.outputs.eligible == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-browser-diagnostics-${{ github.run_id }} @@ -288,17 +267,20 @@ jobs: retention-days: 7 - name: Create the verified npm release bundle once - run: npm run package + if: ${{ steps.registry-state.outputs.eligible == 'true' && steps.recovery.outputs.restored != 'true' }} + run: npm run package:nightly -- --plan "${RUNNER_TEMP}/nightly-plan.json" - name: Create normalized release notes and asset digests + if: ${{ steps.registry-state.outputs.eligible == 'true' }} id: evidence env: LFC_BUNDLE_DIRECTORY: .artifacts/${{ steps.identity.outputs.bundle-name }} LFC_NOTES_DIRECTORY: ${{ runner.temp }}/release-notes + LFC_RESTORED: ${{ steps.recovery.outputs.restored }} LFC_VERSION: ${{ steps.identity.outputs.version }} run: | set -euo pipefail - mkdir -- "${LFC_NOTES_DIRECTORY}" + mkdir -p -- "${LFC_NOTES_DIRECTORY}" export LFC_NOTES_PATH="${LFC_NOTES_DIRECTORY}/release-notes.md" node --input-type=module <<'NODE' import { appendFile, readFile, writeFile } from "node:fs/promises"; @@ -307,14 +289,20 @@ jobs: const changelog = await readFile("CHANGELOG.md", "utf8"); const version = process.env.LFC_VERSION; - const heading = `## [${version}]`; + const heading = "## [Unreleased]"; const start = changelog.indexOf(heading); const next = changelog.indexOf("\n## [", start + heading.length); if (start < 0) { throw new Error(`CHANGELOG.md is missing ${heading}.`); } - const notes = changelog.slice(start, next < 0 ? undefined : next).trim() + "\n"; - await writeFile(process.env.LFC_NOTES_PATH, notes, "utf8"); + const notes = `# Nightly ${version}\n\nSource: ${process.env.GITHUB_SHA}\n\n` + changelog.slice(start + heading.length, next < 0 ? undefined : next).trim() + "\n"; + if (process.env.LFC_RESTORED === "true") { + if (await readFile(process.env.LFC_NOTES_PATH, "utf8") !== notes) { + throw new Error("Retained notes differ from the exact source and nightly identity."); + } + } else { + await writeFile(process.env.LFC_NOTES_PATH, notes, "utf8"); + } const directory = process.env.LFC_BUNDLE_DIRECTORY; const expected = [ @@ -342,6 +330,7 @@ jobs: NODE - name: Upload the exact npm release bundle + if: ${{ steps.registry-state.outputs.eligible == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.identity.outputs.bundle-artifact }} @@ -351,6 +340,7 @@ jobs: retention-days: 30 - name: Upload normalized release notes + if: ${{ steps.registry-state.outputs.eligible == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.identity.outputs.notes-artifact }} @@ -362,6 +352,7 @@ jobs: stage-release: name: Stage exact tag, draft, and release assets needs: verify + if: ${{ needs.verify.outputs.eligible == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 @@ -394,6 +385,7 @@ jobs: LFC_NOTES_PATH: ${{ runner.temp }}/release-notes/release-notes.md LFC_NOTES_SHA256: ${{ needs.verify.outputs.notes-sha256 }} LFC_SOURCE_COMMIT: ${{ needs.verify.outputs.source-commit }} + LFC_BASE_VERSION: ${{ needs.verify.outputs.base-version }} LFC_TAG: ${{ needs.verify.outputs.tag }} run: | set -euo pipefail @@ -479,13 +471,13 @@ jobs: printf 'release-id=%s\n' "${release_id}" >> "${GITHUB_OUTPUT}" publish: - name: Publish or resume the verified npm alpha + name: Publish or resume the verified npm nightly needs: - stage-release - verify runs-on: ubuntu-latest timeout-minutes: 12 - environment: npm + environment: npm-nightly permissions: actions: read @@ -510,9 +502,10 @@ jobs: - name: Validate the serialized registry predecessor env: GH_TOKEN: ${{ github.token }} - LFC_REGISTRY_ALPHA: ${{ needs.verify.outputs.registry-alpha }} + LFC_REGISTRY_PREDECESSOR: ${{ needs.verify.outputs.registry-predecessor }} LFC_REGISTRY_STATE_SHA256: ${{ needs.verify.outputs.registry-state-sha256 }} LFC_SOURCE_COMMIT: ${{ needs.verify.outputs.source-commit }} + LFC_BASE_VERSION: ${{ needs.verify.outputs.base-version }} LFC_VERSION: ${{ needs.verify.outputs.version }} run: | set -euo pipefail @@ -551,18 +544,18 @@ jobs: exit 1 fi - registry_alpha="$(jq --raw-output '."dist-tags".alpha // empty' channel.json)" + registry_predecessor="$(jq --raw-output '."dist-tags".nightly // ."dist-tags".alpha // empty' channel.json)" candidate_exists="$( jq --arg candidate "${LFC_VERSION}" --raw-output ' (.versions | if type == "string" then [.] else . end) | contains([$candidate]) ' channel.json )" - test "${registry_alpha}" = "${LFC_REGISTRY_ALPHA}" + test "${registry_predecessor}" = "${LFC_REGISTRY_PREDECESSOR}" if [[ "${candidate_exists}" == "true" ]]; then - if [[ "${registry_alpha}" != "${LFC_VERSION}" ]]; then - echo "The candidate exists on npm but alpha does not select it; refusing ambiguous registry state." >&2 + if [[ "${registry_predecessor}" != "${LFC_VERSION}" ]]; then + echo "The candidate exists on npm but nightly does not select it; refusing ambiguous registry state." >&2 exit 1 fi exit 0 @@ -589,53 +582,53 @@ jobs: "repos/${GITHUB_REPOSITORY}/contents/package.json?ref=${main_commit}" | jq --exit-status --raw-output '.version' )" - if [[ "${main_version}" != "${LFC_VERSION}" ]]; then + if [[ "${main_version}" != "${LFC_BASE_VERSION}" ]]; then echo "main now selects ${main_version}; refusing stale unpublished candidate ${LFC_VERSION}." >&2 exit 1 fi - if [[ ! "${registry_alpha}" =~ ^0\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ || - "${registry_alpha}" == "${LFC_VERSION}" ]]; then - echo "The verified npm alpha predecessor is missing or malformed." >&2 + if [[ ! "${registry_predecessor}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha\.[0-9]+|nightly\.[0-9]{14}\.[0-9]+)$ || + "${registry_predecessor}" == "${LFC_VERSION}" ]]; then + echo "The verified npm predecessor is missing or malformed." >&2 exit 1 fi - tag_ref="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${registry_alpha}")" + tag_ref="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${registry_predecessor}")" tag_object_type="$(jq --exit-status --raw-output '.object.type' <<< "${tag_ref}")" tag_object="$( jq --exit-status --raw-output ' if (.object.sha | type == "string" and test("^[0-9a-f]{40}$")) then .object.sha - else error("The registry alpha tag must identify one Git object.") + else error("The registry predecessor tag must identify one Git object.") end ' <<< "${tag_ref}" )" if [[ "${tag_object_type}" == "tag" ]]; then tag="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}")" - registry_alpha_commit="$( + registry_predecessor_commit="$( jq --exit-status --raw-output ' if .object.type == "commit" and (.object.sha | type == "string" and test("^[0-9a-f]{40}$")) then .object.sha - else error("The registry alpha tag must peel to one commit.") + else error("The registry predecessor tag must peel to one commit.") end ' <<< "${tag}" )" elif [[ "${tag_object_type}" == "commit" ]]; then - case "${registry_alpha}:${tag_object}" in + case "${registry_predecessor}:${tag_object}" in "0.1.0-alpha.0:53cf1fbb5f4176929c3105030a62e1d0c235b54f"|"0.2.0-alpha.0:8250ac4da9ada72a2915b8f810be404667ab47da") - registry_alpha_commit="${tag_object}" + registry_predecessor_commit="${tag_object}" ;; *) - echo "The registry alpha tag must be annotated; only the exact historical v0.1.0-alpha.0 and v0.2.0-alpha.0 tags are exempt." >&2 + echo "The registry predecessor tag must be annotated; only the exact historical v0.1.0-alpha.0 and v0.2.0-alpha.0 tags are exempt." >&2 exit 1 ;; esac else - echo "The registry alpha tag must be annotated; only the exact historical v0.1.0-alpha.0 and v0.2.0-alpha.0 tags are exempt." >&2 + echo "The registry predecessor tag must be annotated; only the exact historical v0.1.0-alpha.0 and v0.2.0-alpha.0 tags are exempt." >&2 exit 1 fi comparison="$( - gh api "repos/${GITHUB_REPOSITORY}/compare/${registry_alpha_commit}...${LFC_SOURCE_COMMIT}" + gh api "repos/${GITHUB_REPOSITORY}/compare/${registry_predecessor_commit}...${LFC_SOURCE_COMMIT}" )" jq --exit-status ' .status == "ahead" or .status == "identical" @@ -655,6 +648,7 @@ jobs: LFC_BUNDLE_DIRECTORY: ${{ runner.temp }}/release-bundle LFC_REGISTRY_STATE_SHA256: ${{ needs.verify.outputs.registry-state-sha256 }} LFC_SOURCE_COMMIT: ${{ needs.verify.outputs.source-commit }} + LFC_BASE_VERSION: ${{ needs.verify.outputs.base-version }} LFC_TAG: ${{ needs.verify.outputs.tag }} LFC_VERSION: ${{ needs.verify.outputs.version }} run: | @@ -674,6 +668,9 @@ jobs: jq --exit-status --argjson expected "${LFC_ASSET_DIGESTS}" \ '. == $expected' <<< "${local_digests}" > /dev/null + gh api -H "Accept: application/vnd.github.raw+json" "repos/${GITHUB_REPOSITORY}/contents/package.json?ref=${LFC_SOURCE_COMMIT}" > source-package.json + export LFC_SOURCE_MANIFEST="$( packed-package.json @@ -684,6 +681,7 @@ jobs: const receipt = JSON.parse(process.env.LFC_RECEIPT); const manifest = JSON.parse(process.env.LFC_MANIFEST); + const sourceManifest = JSON.parse(process.env.LFC_SOURCE_MANIFEST); const tarball = await readFile(process.env.LFC_TARBALL); const sha256 = createHash("sha256").update(tarball).digest("hex"); const repository = typeof manifest.repository === "string" @@ -698,16 +696,33 @@ jobs: "bundledDependencies", "bundleDependencies" ]; - if (receipt.schemaVersion !== 1 || + if (receipt.schemaVersion !== 2 || receipt.name !== "@tryagaindev/litefold-calendar" || receipt.version !== process.env.LFC_VERSION || receipt.sourceCommit !== process.env.LFC_SOURCE_COMMIT || receipt.sourceTreeDirty !== false || receipt.sha256 !== sha256 || + receipt.sourceVersion !== process.env.LFC_BASE_VERSION || + receipt.nightly?.sourceCommit !== receipt.sourceCommit || + receipt.nightly?.version !== receipt.version || + receipt.nightly?.runId !== process.env.GITHUB_RUN_ID || + receipt.nightly?.workflow !== ".github/workflows/publish-nightly.yml" || + receipt.manifestTransform !== "version-only" || + receipt.browserTargets?.resolvedAt !== receipt.nightly.createdAt?.slice(0, 10) || + receipt.browserTargets?.query !== "baseline-widely-available" || + receipt.browserTargets?.dataVersions?.vite !== sourceManifest.devDependencies?.vite || + receipt.browserTargets?.dataVersions?.esbuild !== sourceManifest.devDependencies?.esbuild || + receipt.browserTargets?.effectiveQuery !== `Vite ${sourceManifest.devDependencies?.vite}: baseline-widely-available` || + !Array.isArray(receipt.browserTargets?.javascript) || receipt.browserTargets.javascript.length === 0 || + !Array.isArray(receipt.browserTargets?.css) || receipt.browserTargets.css.length === 0 || + receipt.browserTargetsSha256 !== createHash("sha256").update(JSON.stringify(receipt.browserTargets)).digest("hex") || + receipt.sourceManifestSha256 !== createHash("sha256").update(JSON.stringify(sourceManifest)).digest("hex") || + JSON.stringify({ ...sourceManifest, version: receipt.version }) !== JSON.stringify(manifest) || + receipt.publishedManifestSha256 !== createHash("sha256").update(JSON.stringify(manifest)).digest("hex") || manifest.name !== receipt.name || manifest.version !== receipt.version || manifest.private !== false || manifest.license !== "MIT" || repository !== "git+https://github.com/tryagaindev/litefold-calendar.git" || manifest.publishConfig?.access !== "public" || - manifest.publishConfig?.tag !== "alpha" || + manifest.publishConfig?.tag !== "nightly" || manifest.publishConfig?.provenance !== true || forbiddenDependencyFields.some((field) => Object.hasOwn(manifest, field)) || Object.keys(manifest.scripts ?? {}).some((name) => forbiddenLifecycle.test(name))) { @@ -798,7 +813,7 @@ jobs: "repos/${GITHUB_REPOSITORY}/contents/package.json?ref=${main_commit}" | jq --exit-status --raw-output '.version' )" - if [[ "${main_version}" != "${LFC_VERSION}" ]]; then + if [[ "${main_version}" != "${LFC_BASE_VERSION}" ]]; then echo "main now selects ${main_version}; refusing stale unpublished candidate ${LFC_VERSION}." >&2 exit 1 fi @@ -806,7 +821,7 @@ jobs: npm publish "${LFC_TARBALL}" \ --registry https://registry.npmjs.org/ \ --access public \ - --tag alpha \ + --tag nightly \ --provenance \ --ignore-scripts else @@ -816,7 +831,7 @@ jobs: printf 'expected-integrity=%s\n' "${expected_integrity}" >> "${GITHUB_OUTPUT}" printf 'publication=%s\n' "${publication}" >> "${GITHUB_OUTPUT}" - echo "::notice::An authenticated npm package maintainer must ensure latest selects this alpha: npm dist-tag add @tryagaindev/litefold-calendar@${LFC_VERSION} latest --registry https://registry.npmjs.org/" + verify-registry: name: Verify registry integrity, imports, and provenance @@ -850,6 +865,7 @@ jobs: - name: Verify the published package from a clean consumer env: LFC_EXPECTED_INTEGRITY: ${{ needs.publish.outputs.expected-integrity }} + LFC_EXPECTED_LATEST: ${{ needs.verify.outputs.registry-latest }} LFC_SOURCE_COMMIT: ${{ needs.verify.outputs.source-commit }} LFC_TARBALL: ${{ runner.temp }}/release-bundle/${{ needs.verify.outputs.bundle-name }}.tgz LFC_VERSION: ${{ needs.verify.outputs.version }} @@ -870,31 +886,49 @@ jobs: > "${integrity_raw}" 2>/dev/null; then node --input-type=module --eval "${LFC_NORMALIZE_NPM_VIEW_JSON}" \ "${integrity_raw}" "${integrity_json}" - if npm view @tryagaindev/litefold-calendar dist-tags \ + if npm view @tryagaindev/litefold-calendar versions dist-tags \ --registry https://registry.npmjs.org/ --json \ > "${tags_raw}" 2>/dev/null; then node --input-type=module --eval "${LFC_NORMALIZE_NPM_VIEW_JSON}" \ "${tags_raw}" "${tags_json}" - integrity="$(jq -r 'if type == "string" then . else empty end' "${integrity_json}")" - alpha="$(jq -r '.alpha // empty' "${tags_json}")" - latest="$(jq -r '.latest // empty' "${tags_json}")" - if [[ -n "${integrity}" && "${integrity}" != "${LFC_EXPECTED_INTEGRITY}" ]]; then - echo "npm integrity conflicts with the verified release bundle." >&2 - exit 1 - fi - if [[ "${integrity}" == "${LFC_EXPECTED_INTEGRITY}" && - "${alpha}" == "${LFC_VERSION}" && - "${latest}" == "${LFC_VERSION}" ]]; then + # LFC_NIGHTLY_READBACK_POLICY_START + if node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + const root = process.env.RUNNER_TEMP; + const metadata = JSON.parse(readFileSync(`${root}/registry-tags.json`, "utf8")); + const integrity = JSON.parse(readFileSync(`${root}/registry-integrity.json`, "utf8")); + const versions = typeof metadata.versions === "string" ? [metadata.versions] : metadata.versions; + const tags = metadata["dist-tags"]; + if (integrity !== null && integrity !== process.env.LFC_EXPECTED_INTEGRITY) { + throw new Error("npm integrity conflicts with the verified release bundle."); + } + if (!Array.isArray(versions) || !versions.includes(process.env.LFC_VERSION) || + tags?.nightly !== process.env.LFC_VERSION || integrity === null) { + process.exit(75); + } + const stable = (version) => typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/u.test(version); + if (tags.latest !== process.env.LFC_EXPECTED_LATEST || !versions.includes(tags.latest)) { + throw new Error("npm latest changed from the verified preflight snapshot; nightly cannot modify it."); + } + if (versions.some(stable) ? !stable(tags.latest) : tags.latest !== tags.alpha) { + throw new Error("npm latest must remain on historical alpha until stable takes ownership."); + } + NODE + # LFC_NIGHTLY_READBACK_POLICY_END + then verified=true break + else + status=$? + if [[ "${status}" != "75" ]]; then exit "${status}"; fi fi fi fi [[ "${attempt}" -lt 60 ]] && sleep 10 done if [[ "${verified}" != "true" ]]; then - echo "npm did not expose the exact package integrity and both required dist-tags within the polling window." >&2 - echo "Check for pending or blocked publish-time review before changing registry metadata. Advance latest only after the exact candidate is publicly readable with the expected integrity." >&2 + echo "npm did not expose the exact package integrity, nightly tag, and unchanged latest tag within the polling window." >&2 + echo "Check for pending or blocked publish-time review before changing registry metadata. Resume this exact attempt after correcting the reported registry or registry verification failure." >&2 exit 1 fi @@ -919,6 +953,8 @@ jobs: cd "${consumer}" node --input-type=module --eval ' await import("@tryagaindev/litefold-calendar"); + const { webMcp } = await import("@tryagaindev/litefold-calendar/extensions/webmcp"); + if (typeof webMcp !== "function") throw new Error("Missing published WebMCP extension factory."); const style = import.meta.resolve("@tryagaindev/litefold-calendar/styles.css"); if (!style.endsWith("/dist/styles.css")) throw new Error(`Unexpected style export ${style}`); ' @@ -981,11 +1017,11 @@ jobs: # LFC_GH_VERSION_POLICY_END # The exact workflow identity below binds the signed package to this reviewed source, - # whose sole OIDC publisher declares environment: npm. Protection is platform policy. + # whose sole OIDC publisher declares environment: npm-nightly. Protection is platform policy. gh attestation verify "${LFC_TARBALL}" \ --bundle provenance.sigstore.json \ --repo "${GITHUB_REPOSITORY}" \ - --cert-identity "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/.github/workflows/publish-alpha.yml@refs/heads/main" \ + --cert-identity "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/.github/workflows/publish-nightly.yml@refs/heads/main" \ --cert-oidc-issuer https://token.actions.githubusercontent.com \ --source-ref refs/heads/main \ --source-digest "${LFC_SOURCE_COMMIT}" \ @@ -1033,14 +1069,14 @@ jobs: const sourceCommit = process.env.LFC_SOURCE_COMMIT; const sourceRef = "refs/heads/main"; const eventName = process.env.GITHUB_EVENT_NAME; - const workflowPath = ".github/workflows/publish-alpha.yml"; + const workflowPath = ".github/workflows/publish-nightly.yml"; const workflowIdentity = `${repositoryUrl}/${workflowPath}@${sourceRef}`; const workflowRef = `${repository}/${workflowPath}@${sourceRef}`; requireValue(server === "https://github.com" && repository.toLowerCase() === "tryagaindev/litefold-calendar" && process.env.GITHUB_REPOSITORY_OWNER.toLowerCase() === "tryagaindev", "Current workflow is not the canonical release repository."); - requireValue(eventName === "push" && + requireValue(["schedule", "workflow_dispatch"].includes(eventName) && process.env.GITHUB_REF === sourceRef && process.env.GITHUB_SHA === sourceCommit && process.env.GITHUB_WORKFLOW_REF === workflowRef && @@ -1048,8 +1084,8 @@ jobs: "Current workflow identity does not match the release source."); requireValue(/^[0-9a-f]{40}$/u.test(sourceCommit), "Release provenance source commit must be a full lowercase SHA."); - requireValue(/^0\.[0-9]+\.[0-9]+-alpha\.[0-9]+$/u.test(process.env.LFC_VERSION), - "Release provenance version must be an exact supported alpha."); + requireValue(/^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{14}\.[0-9]+$/u.test(process.env.LFC_VERSION), + "Release provenance version must be an exact generated nightly."); const integrityMatch = /^sha512-([A-Za-z0-9+/]+={0,2})$/u.exec( process.env.LFC_EXPECTED_INTEGRITY @@ -1166,6 +1202,7 @@ jobs: LFC_NOTES_SHA256: ${{ needs.verify.outputs.notes-sha256 }} LFC_RELEASE_ID: ${{ needs.stage-release.outputs.release-id }} LFC_SOURCE_COMMIT: ${{ needs.verify.outputs.source-commit }} + LFC_BASE_VERSION: ${{ needs.verify.outputs.base-version }} LFC_TAG: ${{ needs.verify.outputs.tag }} run: | set -euo pipefail diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index 994d066..824fd15 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -35,14 +35,20 @@ Event representation follows native semantics: Direct event activation on either surface never selects the day or invokes `onDaySelect`. The selected-day agenda remains below the grid at every width and uses ordered-list markup. -Grid overflow is a native action named from its date and hidden count; its default label is “View {count} more {eventLabel} for {date}”. `renderEventOverflow` may replace its pre-rendered compact and wide visual content, but each custom slot is `aria-hidden` and noninteractive. The native button, accessible label, activation, and agenda-focus transfer remain package-owned; `context.text` retains the package-formatted fallback value even when custom content replaces that fallback visually. Activating overflow selects the represented date, resets agenda expansion, focuses the agenda heading, and does not invoke `onDaySelect`. +Day counts and grid overflow share a native action. Whenever either container-size variant presents a total count, its stable accessible label uses the full count and date: “View {count} {eventLabel} for {date}”. If the wide variant presents remaining events, its localized visible wording is also included in that name, such as “View 4 events for Tuesday, July 14, 2026, 1 more”. Otherwise the label describes the hidden count with “View {count} more {eventLabel} for {date}”. `renderEventOverflow` customizes only `aria-hidden`, noninteractive visual content; count actions cannot be suppressed by returning `null`. -Replacing event input keeps package-owned focus on the same day or event occurrence when it still exists. If a focused event disappears, focus returns to its owning day; focus outside the calendar is not moved. +Activation invokes `onEventOverflowActivate` with an immutable occurrence snapshot before selection or DOM replacement. Synchronous `nativeEvent.preventDefault()` transfers interaction and focus ownership to the application, which may open its own event chooser and restore focus when it closes. Without cancellation, activation selects the day, resets agenda expansion, focuses the agenda heading, and does not invoke `onDaySelect`. Application navigation, rerendering, or destruction during the callback invalidates the original action and prevents stale default work; asynchronous failures use the action-error channel. + +At the supported year limits, a spillover occurrence may belong to a month whose complete six-week grid cannot be rendered. Its application callback still runs, but default navigation leaves selection unchanged, matching the day proxy's boundary behavior. + +Replacing or refetching event input keeps package-owned focus on the same day or event occurrence when its replacement remains available for focus. An event preserved visually while focused across a container-size change may become hidden after a data rerender; focus then moves to the day's available action, normally its total-count button in count presentation, or falls back to the day proxy. If a focused event disappears, focus returns to its owning day. Focus outside the calendar is not moved. `eventTimeDisplay` controls visual time exposure without removing information from assistive technology. For `"grid"`, `"agenda"`, and `"none"`, a suppressed surface still contains its native `