From bbe5274f412afe86624aa78d712895d49291b9c3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:14:20 +0700 Subject: [PATCH 01/11] ci(cinema): test PRs only and cancel superseded CI runs --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e62509..95fe4482 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,17 @@ name: CI +# Production pushes already run the same build/smoke tests in pages-stage. +# Run CI for pull requests only; superseded commits never need finishing. on: pull_request: - push: - branches: - - main permissions: contents: read +concurrency: + group: cinema-ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build: name: Build Astro preview From 56c2c2db895f3ab26337663b06af1c8c44e3b63e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:14:44 +0700 Subject: [PATCH 02/11] ci(cinema): serialize staging and gate stale or already-published PR/main revisions --- .github/workflows/pages-stage.yml | 65 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pages-stage.yml b/.github/workflows/pages-stage.yml index 3e92d0d1..e71e1bcb 100644 --- a/.github/workflows/pages-stage.yml +++ b/.github/workflows/pages-stage.yml @@ -9,11 +9,16 @@ on: permissions: contents: write + pull-requests: read + actions: write -# One writer at a time to keep production and all PR previews intact. +# Serialize gh-pages writes; never interrupt a publishing write. +# Queue all distinct PR-close and main events, then skip stale/duplicate runs +# cheaply before installing dependencies. concurrency: group: cinema-pages-stage cancel-in-progress: false + queue: max jobs: stage: @@ -70,32 +75,82 @@ jobs: echo "preview_url=${PAGES_SITE_ORIGIN}${BASE_PATH}" >> "$GITHUB_OUTPUT" echo "Deploy kind: $KIND | Base path: $BASE_PATH" + - name: Skip superseded and already-staged events + id: gate + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + PR_ACTION: ${{ github.event.action || '' }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} + EXPECTED_SHA: ${{ github.sha }} + run: | + set -euo pipefail + should_run=true + reason="new content" + read_marker() { + gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=gh-pages" --jq '.content' 2>/dev/null | base64 --decode 2>/dev/null || true + } + if [[ "$EVENT_NAME" == "pull_request" ]]; then + current_pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + state="$(jq -r '.state' <<< "$current_pr")" + actual_sha="$(jq -r '.head.sha' <<< "$current_pr")" + if [[ "$PR_ACTION" == "closed" ]]; then + if [[ "$state" == "open" ]]; then + should_run=false + reason="PR reopened after close event" + fi + elif [[ "$state" != "open" || "$actual_sha" != "$PR_HEAD_SHA" ]]; then + should_run=false + reason="PR closed or replaced by a newer commit" + elif [[ "$(read_marker "previews/pr-${PR_NUMBER}/.eplus-preview-sha")" == "$PR_HEAD_SHA" ]]; then + should_run=false + reason="same PR commit already staged" + fi + else + actual_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then + should_run=false + reason="superseded main commit" + elif [[ "$EVENT_NAME" == "push" && "$(read_marker '.eplus-production-sha')" == "$EXPECTED_SHA" ]]; then + should_run=false + reason="same main commit already staged" + fi + fi + echo "run=$should_run" >> "$GITHUB_OUTPUT" + echo "Stage gate: $should_run ($reason)" + echo "Stage gate: $should_run ($reason)" >> "$GITHUB_STEP_SUMMARY" + - name: Setup Node - if: github.event.action != 'closed' + if: steps.gate.outputs.run == 'true' && github.event.action != 'closed' uses: actions/setup-node@v4 with: node-version: '22' - name: Install dependencies - if: github.event.action != 'closed' + if: steps.gate.outputs.run == 'true' && github.event.action != 'closed' run: npm install --no-audit --no-fund - name: Build Astro site - if: github.event.action != 'closed' + if: steps.gate.outputs.run == 'true' && github.event.action != 'closed' run: | npm run build node scripts/rewrite-links.mjs node scripts/smoke-test.mjs node scripts/seat-color-test.mjs node scripts/seo-audit.mjs + node scripts/check-outbound-links.mjs - name: Stage full site and preview into gh-pages + if: steps.gate.outputs.run == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} run: bash scripts/publish-pages.sh - name: Pages publishing summary - if: github.event_name == 'pull_request' + if: steps.gate.outputs.run == 'true' && github.event_name == 'pull_request' env: PREVIEW_URL: ${{ steps.config.outputs.preview_url }} PR_ACTION: ${{ github.event.action }} From 9838e950d897f37d71068da3552f4e98057803d4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:15:18 +0700 Subject: [PATCH 03/11] fix(cinema): mark staged SHAs and dispatch Pages only when gh-pages actually changes --- scripts/publish-pages.sh | 47 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/publish-pages.sh b/scripts/publish-pages.sh index 252fca81..aba0f77f 100644 --- a/scripts/publish-pages.sh +++ b/scripts/publish-pages.sh @@ -6,6 +6,33 @@ set -euo pipefail : "${GITHUB_TOKEN:?A scoped Actions token is required}" : "${PAGES_DEPLOY_KIND:?Production, preview, or delete-preview must be selected}" +# Check again after the build: a PR may close or main may advance mid-build. +# A stale run must never restore an old preview or overwrite a new production. +case "$PAGES_DEPLOY_KIND" in + production) + current_main="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if [[ "$current_main" != "$GITHUB_SHA" ]]; then + echo "Skipped stale production build ($GITHUB_SHA -> $current_main)" + exit 0 + fi + ;; + preview) + pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + if [[ "$(jq -r '.state' <<< "$pr_json")" != "open" || + "$(jq -r '.head.sha' <<< "$pr_json")" != "${PR_HEAD_SHA:-}" ]]; then + echo "Skipped outdated or closed PR #$PR_NUMBER" + exit 0 + fi + ;; + delete-preview) + pr_state="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" + if [[ "$pr_state" == "open" ]]; then + echo "Skipped deletion; PR #$PR_NUMBER reopened" + exit 0 + fi + ;; +esac + repo_url="https://github.com/${GITHUB_REPOSITORY}.git" branch_dir="${RUNNER_TEMP:-/tmp}/eplus-cinema-gh-pages" site_dir=".site-publish" @@ -31,8 +58,10 @@ case "$PAGES_DEPLOY_KIND" in --exclude='/previews/' \ --exclude='/CNAME' \ --exclude='/.nojekyll' \ + --exclude='/.eplus-production-sha' \ dist/ "$branch_dir/" - echo "Staged production site" + printf '%s\n' "$GITHUB_SHA" > "$branch_dir/.eplus-production-sha" + echo "Staged production site at commit $GITHUB_SHA" ;; preview|delete-preview) : "${PR_NUMBER:?PR number is required}" @@ -42,7 +71,8 @@ case "$PAGES_DEPLOY_KIND" in test -f dist/index.html mkdir -p "$destination" rsync -a --delete dist/ "$destination/" - echo "Staged preview at previews/pr-${PR_NUMBER}" + printf '%s\n' "$PR_HEAD_SHA" > "$destination/.eplus-preview-sha" + echo "Staged preview at previews/pr-${PR_NUMBER} ($PR_HEAD_SHA)" else rm -rf -- "$destination" echo "Removed preview for closed PR #${PR_NUMBER}" @@ -64,8 +94,19 @@ if ! git -C "$branch_dir" diff --cached --quiet; then git -C "$branch_dir" push \ "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ HEAD:refs/heads/gh-pages + + # On main, the publisher is dispatched only for actual gh-pages mutations. + # Before this PR merges, the default branch still uses workflow_run; do not + # try to dispatch a trigger it does not yet expose. + publisher="$(gh api "repos/${GITHUB_REPOSITORY}/contents/.github/workflows/pages-publish.yml?ref=main" --jq '.content' | base64 --decode)" + if grep -Eq '^[[:space:]]*workflow_dispatch:' <<< "$publisher"; then + gh workflow run pages-publish.yml --ref main + echo "Dispatched exactly one Pages publish for this staged change" + else + echo "Legacy workflow_run publisher on main handles the PR preview until merge" + fi else - echo "No change in the Pages publication" + echo "No gh-pages changes; no Pages publish dispatched" fi # No .git metadata or token in the upload artifact. From f816eb577464fe5f04a6b8636460e53f9ddb8c00 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:15:35 +0700 Subject: [PATCH 04/11] ci(cinema): trigger Pages deploy only on explicit changed-stage dispatch --- .github/workflows/pages-publish.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pages-publish.yml b/.github/workflows/pages-publish.yml index 9ebaf81e..36729c72 100644 --- a/.github/workflows/pages-publish.yml +++ b/.github/workflows/pages-publish.yml @@ -1,11 +1,10 @@ name: Publish GitHub Pages +# Only the staging workflow dispatches this after it PUSHES a changed +# gh-pages tree. A successful no-op stage must not create another deploy. # This trusted workflow always runs from the default branch. -# PR workflows only stage static files; they cannot deploy Pages directly. on: - workflow_run: - workflows: ["Build and stage GitHub Pages"] - types: [completed] + workflow_dispatch: permissions: contents: read @@ -18,7 +17,7 @@ concurrency: jobs: deploy: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 environment: From 317b5d6b332e81e57ed95beb54ff8ead9290e9bf Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:16:11 +0700 Subject: [PATCH 05/11] fix(cinema): skip same-day catalog refresh and safely rebase on concurrent main updates --- .github/workflows/cinema-catalog-daily.yml | 63 ++++++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cinema-catalog-daily.yml b/.github/workflows/cinema-catalog-daily.yml index fa215919..24cf2228 100644 --- a/.github/workflows/cinema-catalog-daily.yml +++ b/.github/workflows/cinema-catalog-daily.yml @@ -5,6 +5,11 @@ on: schedule: - cron: '10 18 * * *' workflow_dispatch: + inputs: + force: + description: 'Re-fetch even if the catalog was already updated today' + type: boolean + default: false # Bootstrap the first catalog immediately when this new workflow is merged. # Future regular source pushes do NOT start a fetch. push: @@ -37,11 +42,39 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + - name: Skip a second catalog refresh on the same Vietnam date + id: due + shell: bash + env: + FORCE_REFRESH: ${{ inputs.force || false }} + run: | + set -euo pipefail + today="$(TZ=Asia/Ho_Chi_Minh date +%F)" + snapshot_day="$(node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const stamp=JSON.parse(readFileSync("src/data/cinema-snapshot.json","utf8")).fetchedAt; + if (stamp && !Number.isNaN(Date.parse(stamp))) { + console.log(new Intl.DateTimeFormat("en-CA", { + timeZone:"Asia/Ho_Chi_Minh", year:"numeric", month:"2-digit", day:"2-digit" + }).format(new Date(stamp))); + } + ')" + if [[ "$FORCE_REFRESH" != "true" && "$snapshot_day" == "$today" ]]; then + echo "due=false" >> "$GITHUB_OUTPUT" + echo "Catalog already refreshed today ($today); skip duplicate fetch/build/deploy." >> "$GITHUB_STEP_SUMMARY" + else + echo "due=true" >> "$GITHUB_OUTPUT" + echo "Catalog refresh due (today=$today, cached=${snapshot_day:-none})." + fi + - name: Install dependencies + if: steps.due.outputs.due == 'true' run: npm install --no-audit --no-fund - name: Download film and cinema directory only + if: steps.due.outputs.due == 'true' run: node scripts/sync-cinema-catalog.mjs - name: Validate new snapshot and prerendered pages + if: steps.due.outputs.due == 'true' run: | node scripts/sync-cinema-catalog.mjs --check npm run build @@ -50,6 +83,7 @@ jobs: node scripts/seo-audit.mjs node scripts/check-outbound-links.mjs - name: Commit new catalog to main for normal Pages deployment + if: steps.due.outputs.due == 'true' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -63,9 +97,28 @@ jobs: exit 0 fi git commit -m "data(cinema): refresh daily movie and theater snapshot" - # Never force push, rewrite history, or hide branch protections. - git push origin HEAD:main - # GITHUB_TOKEN pushes do NOT trigger regular on:push workflows. - # workflow_dispatch is specifically allowed, so explicitly build/deploy. + # Avoid the non-fast-forward failure observed on the first bootstrap: + # rebase only when main advanced, without force-pushing or overwriting + # another catalog update. A conflict fails closed. + pushed=false + for attempt in 1 2 3; do + git fetch origin main + if ! git rebase origin/main; then + git rebase --abort || true + echo "Main now has conflicting catalog updates; refusing to overwrite." >&2 + exit 1 + fi + if git push origin HEAD:main; then + pushed=true + break + fi + echo "Main advanced during push attempt $attempt; retrying safely." + done + if [[ "$pushed" != "true" ]]; then + echo "Unable to fast-forward main after 3 attempts." >&2 + exit 1 + fi + # GITHUB_TOKEN pushes do not trigger standard on:push workflows. + # Dispatch the normal stage exactly once for this successful commit. gh workflow run pages-stage.yml --ref main - echo "Snapshot committed and Pages deployment explicitly dispatched." + echo "Snapshot committed; one Pages staging run dispatched." From 34723fe80868debc613de688d68d2445ba85917e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:16:48 +0700 Subject: [PATCH 06/11] test(ci): protect workflow de-duplication and single-dispatch invariants --- scripts/check-actions-dedup.mjs | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 scripts/check-actions-dedup.mjs diff --git a/scripts/check-actions-dedup.mjs b/scripts/check-actions-dedup.mjs new file mode 100644 index 00000000..d17f58c9 --- /dev/null +++ b/scripts/check-actions-dedup.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const ci = readFileSync('.github/workflows/ci.yml', 'utf8'); +const stage = readFileSync('.github/workflows/pages-stage.yml', 'utf8'); +const publish = readFileSync('.github/workflows/pages-publish.yml', 'utf8'); +const daily = readFileSync('.github/workflows/cinema-catalog-daily.yml', 'utf8'); +const stagingScript = readFileSync('scripts/publish-pages.sh', 'utf8'); + +// CI is for PRs. The staging workflow validates main after pushes. +assert.match(ci, /on:\s*\n\s*pull_request:/, 'PR CI trigger is missing'); +assert.doesNotMatch(ci.split('permissions:')[0], /^\s*push:/m, 'Main push must not duplicate CI builds'); +assert.match(ci, /cancel-in-progress: true/, 'Superseded PR CI must be cancelled'); + +assert.match(stage, /group: cinema-pages-stage/, 'Pages staging needs a shared writer lock'); +assert.match(stage, /cancel-in-progress: false/, 'Do not interrupt a gh-pages write'); +assert.match(stage, /queue: max/, 'Distinct PR closures must not be lost from the queue'); +assert.match(stage, /id: gate/, 'Stage event/commit dedup gate missing'); +assert.match(stage, /read_marker/, 'Stage should compare previously staged commit'); +assert.match(stage, /steps\.gate\.outputs\.run == 'true'/, 'Build must respect the gate'); +assert.match(stage, /actions: write/, 'Publisher dispatch permission missing'); + +assert.match(publish, /^\s+workflow_dispatch:/m, 'Publisher needs dispatch-only trigger'); +assert.doesNotMatch(publish, /workflow_run:/, 'Publisher must not run on no-op staging success'); +assert.match(publish, /group: cinema-pages-publish/, 'Publisher needs serial deployment'); + +assert.match(stagingScript, /\.eplus-production-sha/, 'Production marker not saved'); +assert.match(stagingScript, /\.eplus-preview-sha/, 'PR preview marker not saved'); +assert.match(stagingScript, /No gh-pages changes; no Pages publish dispatched/, 'No-op stage must not dispatch publish'); +assert.match(stagingScript, /gh workflow run pages-publish\.yml --ref main/, 'Changed stage must dispatch publish'); + +assert.match(daily, /group: cinema-catalog-daily/, 'Catalog refresh concurrency missing'); +assert.match(daily, /steps\.due\.outputs\.due == 'true'/, 'Same-day refresh guard missing'); +assert.match(daily, /git rebase origin\/main/, 'Concurrent main update recovery missing'); +assert.match(daily, /gh workflow run pages-stage\.yml --ref main/, 'GITHUB_TOKEN commit needs explicit stage dispatch'); +assert.equal((daily.match(/cron:/g) || []).length, 1, 'Catalog must have one scheduled cron'); + +console.log('PASS: CI, staging, publishing and catalog dedup invariants'); From 057d2ef8059869f646d7c811beaf3c257928ccbc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:16:59 +0700 Subject: [PATCH 07/11] test(ci): run workflow dedup safety checks on PR and staging --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95fe4482..461278ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: node scripts/seat-color-test.mjs node scripts/seo-audit.mjs node scripts/check-outbound-links.mjs + node scripts/check-actions-dedup.mjs - name: Verify explicitly indexable build run: | SEO_INDEXABLE=true npm run build From eb75ac581c0b5b751bdc0dd2938d36181095ab80 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:17:02 +0700 Subject: [PATCH 08/11] test(ci): run workflow dedup safety checks on PR and staging --- .github/workflows/pages-stage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pages-stage.yml b/.github/workflows/pages-stage.yml index e71e1bcb..f69d3a81 100644 --- a/.github/workflows/pages-stage.yml +++ b/.github/workflows/pages-stage.yml @@ -140,6 +140,7 @@ jobs: node scripts/seat-color-test.mjs node scripts/seo-audit.mjs node scripts/check-outbound-links.mjs + node scripts/check-actions-dedup.mjs - name: Stage full site and preview into gh-pages if: steps.gate.outputs.run == 'true' From d21d86f99c02f1ab504612db1c6a5b4227a81b3e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:19:01 +0700 Subject: [PATCH 09/11] docs(cinema): document job dedup and safe Pages deploy triggers --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index ef5f0fcc..6895732b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,33 @@ sections inside the empty `
` before the footer and normalizes preview links. This runs automatically after `npm run build`. The smoke tests verify that all primary pages have valid main/footer order. +### GitHub Actions deduplication + +- **PR CI** runs only on pull requests. New commits cancel obsolete PR + checks through a per-PR concurrency group. Production `main` builds and + smoke checks are performed by Pages Staging, not duplicate CI jobs. +- **Pages Staging** serializes writes to the shared `gh-pages` branch. + Separate PR-close/production events remain queued, but stale PR HEADs, + superseded `main` SHA values and already-staged commits are detected + before dependency installation. Each staged preview carries an + `.eplus-preview-sha` marker; production has `.eplus-production-sha`. + HEAD is verified again immediately before publishing. +- **Pages Publishing** is dispatched only after a successful push that + actually changes `gh-pages`. A stage with no changes **does not** + dispatch another Pages deployment. The deployment workflow runs from + `main` with `workflow_dispatch`, not `workflow_run: completed`. +- **Catalog refresh** still has one daily schedule at 01:10 Vietnam time. + If the snapshot was already fetched on that Vietnam calendar date, the + job skips loading and deployment. A manual workflow run with + `force=true` explicitly overrides the same-day check. Bot commits fetch + and rebase against the latest `main` before attempting a non-force push; + conflicts fail closed, preserving existing data. +- GitHub Actions concurrency itself only prevents overlapping executions; + preserving independent preview deletions is why staging uses + `queue: max` with per-event freshness checks, not a blanket + `cancel-in-progress: true`. See `scripts/check-actions-dedup.mjs` for + CI-validated safety invariants. + ## GitHub Pages deployment Two workflows work together: From 7189968a0e48a5054da52fb3dba75fb4d790b114 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:19:22 +0700 Subject: [PATCH 10/11] docs(cinema): align Pages deployment guide with changed-only dispatch --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6895732b..f55c28e0 100644 --- a/README.md +++ b/README.md @@ -139,17 +139,17 @@ Two workflows work together: - `workflow_dispatch`: manually rebuild production from `main`. - Builds run with `persist-credentials: false`; the publish step receives the scoped `GITHUB_TOKEN` separately. 2. **Publish GitHub Pages** (`.github/workflows/pages-publish.yml`): - - Runs from the repository's **default branch** after the staging workflow succeeds. + - Runs from the repository's **default branch** only when staging pushes a **real change** to `gh-pages` and dispatches the publisher. - Checks out the complete `gh-pages` branch, uploads one Pages artifact and deploys it with GitHub's official `actions/deploy-pages`. - Does not execute PR source code and avoids deploying directly from a PR context. -This staging/deployment split is necessary because public GitHub Pages does not offer native isolated PR preview deployments, and commits made by `GITHUB_TOKEN` alone do **not** reliably trigger a Pages publish. Our preview folders are part of the same complete GitHub Pages site. +This staging/deployment split is necessary because public GitHub Pages does not offer native isolated PR preview deployments. Commits made by `GITHUB_TOKEN` do not trigger normal push workflows, so staging explicitly sends one `workflow_dispatch` after each changed `gh-pages` commit. A no-op staging run sends none. Preview folders are part of the same complete Pages site. ### One-time GitHub setup (required) 1. Merge the deployment PR so both workflow files are on `main`. 2. Visit **Repository → Settings → Pages** and set **Build and deployment → Source → GitHub Actions**. Do **not** select `Deploy from a branch` for this configuration. -3. In **Settings → Actions → General**, ensure workflows can use the repository-scoped `GITHUB_TOKEN` with the requested `contents: write` permission. If your organization restricts writable workflow tokens, an administrator must allow the necessary policy. +3. In **Settings → Actions → General**, ensure workflows can use the scoped `GITHUB_TOKEN` with `contents: write` and `actions: write` as requested. If the organization restricts writable workflow tokens, an administrator must adjust the repository policy. 4. Under **Settings → Environments → github-pages**, allow default-branch deployments. The trusted publishing workflow runs from `main`. 5. Trigger **Actions → Build and stage GitHub Pages → Run workflow** on `main` for the first production release (or use the automatic `main` push). 6. Check **Actions → Publish GitHub Pages** for successful deployment. @@ -206,7 +206,7 @@ scripts/ .github/workflows/ ci.yml # Pull-request static build pages-stage.yml # Build and stage production / previews - pages-publish.yml # Official GitHub Pages deployment + pages-publish.yml # Dispatch-only GitHub Pages deployment ``` ## Real showtime data from MoMo (experimental) From 63119315aaa061743eb33ae87cea8925ce5d2b57 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 18:20:47 +0700 Subject: [PATCH 11/11] fix(cinema): compare same-day catalog refresh using locale-independent VN date --- .github/workflows/cinema-catalog-daily.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cinema-catalog-daily.yml b/.github/workflows/cinema-catalog-daily.yml index 24cf2228..97deb417 100644 --- a/.github/workflows/cinema-catalog-daily.yml +++ b/.github/workflows/cinema-catalog-daily.yml @@ -54,9 +54,12 @@ jobs: import { readFileSync } from "node:fs"; const stamp=JSON.parse(readFileSync("src/data/cinema-snapshot.json","utf8")).fetchedAt; if (stamp && !Number.isNaN(Date.parse(stamp))) { - console.log(new Intl.DateTimeFormat("en-CA", { - timeZone:"Asia/Ho_Chi_Minh", year:"numeric", month:"2-digit", day:"2-digit" - }).format(new Date(stamp))); + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-GB", { + timeZone:"Asia/Ho_Chi_Minh", year:"numeric", month:"2-digit", day:"2-digit" + }).formatToParts(new Date(stamp)).map(item => [item.type, item.value]) + ); + console.log(parts.year + "-" + parts.month + "-" + parts.day); } ')" if [[ "$FORCE_REFRESH" != "true" && "$snapshot_day" == "$today" ]]; then