Skip to content

Commit 8af36d4

Browse files
Verify documentation deployments
1 parent 3c037a2 commit 8af36d4

8 files changed

Lines changed: 377 additions & 5 deletions

File tree

.github/workflows/gh-pages-deploy.yaml

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ permissions:
1212

1313
concurrency:
1414
group: pages
15-
cancel-in-progress: false
15+
cancel-in-progress: true
1616

1717
jobs:
1818
build:
@@ -45,6 +45,7 @@ jobs:
4545
run: |
4646
pnpm build
4747
pnpm cli validate-links .vitepress/dist --public-path /iroha-docs/
48+
pnpm cli validate-locales .vitepress/dist --public-path /iroha-docs/
4849
env:
4950
FORCE_COLOR: 2
5051
PUBLIC_PATH: /iroha-docs/
@@ -65,3 +66,53 @@ jobs:
6566
- name: Deploy
6667
id: deployment
6768
uses: actions/deploy-pages@v4
69+
70+
verify-production:
71+
runs-on: ubuntu-latest
72+
needs: deploy
73+
timeout-minutes: 6
74+
steps:
75+
- name: Check for a superseding deployment
76+
id: current
77+
env:
78+
GH_TOKEN: ${{ github.token }}
79+
run: |
80+
current_sha="$(
81+
curl --fail --silent --show-error \
82+
--header "Authorization: Bearer $GH_TOKEN" \
83+
--header "X-GitHub-Api-Version: 2022-11-28" \
84+
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/commits/main" |
85+
jq --raw-output .sha
86+
)"
87+
if [[ "$current_sha" != "$GITHUB_SHA" ]]; then
88+
echo "Commit $GITHUB_SHA was superseded by $current_sha; skipping canonical verification."
89+
echo "superseded=true" >> "$GITHUB_OUTPUT"
90+
fi
91+
92+
- name: Verify canonical deployment
93+
if: steps.current.outputs.superseded != 'true'
94+
env:
95+
EXPECTED_REVISION: ${{ github.sha }}
96+
SITE_URL: https://docs.iroha.tech
97+
run: |
98+
deadline=$((SECONDS + 300))
99+
attempt=0
100+
while ((SECONDS < deadline)); do
101+
attempt=$((attempt + 1))
102+
html="$(curl --connect-timeout 3 --max-time 5 --fail --silent --show-error "$SITE_URL/" || true)"
103+
if [[ "$html" == *"iroha-docs-revision"* ]] && \
104+
[[ "$html" == *"$EXPECTED_REVISION"* ]] && \
105+
[[ "$html" == *"VPNavBarTranslations"* ]] && \
106+
curl --connect-timeout 3 --max-time 5 --fail --silent --show-error --output /dev/null "$SITE_URL/es/"; then
107+
echo "Canonical deployment is current and exposes translated navigation."
108+
exit 0
109+
fi
110+
echo "Attempt $attempt: waiting for canonical deployment $EXPECTED_REVISION"
111+
if ((SECONDS + 10 >= deadline)); then
112+
break
113+
fi
114+
sleep 10
115+
done
116+
117+
echo "::error::Canonical site did not publish $EXPECTED_REVISION with translated navigation."
118+
exit 1

.github/workflows/pull-request-ci.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,18 @@ jobs:
4949
env:
5050
FORCE_COLOR: 2
5151

52+
- name: Validate language selector
53+
run: pnpm cli validate-locales .vitepress/dist
54+
5255
- name: Build GitHub Pages backup
5356
run: pnpm build
5457
env:
5558
FORCE_COLOR: 2
5659
PUBLIC_PATH: /iroha-docs/
5760

5861
- name: Validate GitHub Pages backup links
59-
run: pnpm cli validate-links .vitepress/dist --public-path /iroha-docs/
62+
run: |
63+
pnpm cli validate-links .vitepress/dist --public-path /iroha-docs/
64+
pnpm cli validate-locales .vitepress/dist --public-path /iroha-docs/
6065
env:
6166
FORCE_COLOR: 2

.vitepress/config.mts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="vite/client" />
22

3-
import { DefaultTheme, defineConfig } from 'vitepress'
3+
import { DefaultTheme, defineConfig, type HeadConfig } from 'vitepress'
44
import footnote from 'markdown-it-footnote'
55
import { resolve } from 'path'
66
import ViteSvgLoader from 'vite-svg-loader'
@@ -508,6 +508,10 @@ const THEME_LOCALES = Object.fromEntries(
508508

509509
const PUBLIC_BASE = process.env.PUBLIC_PATH ?? '/'
510510
const publicAsset = (name: string): string => `${PUBLIC_BASE}${name}`
511+
const BUILD_REVISION = process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.GITHUB_SHA
512+
const revisionHead: HeadConfig[] = BUILD_REVISION
513+
? [['meta', { name: 'iroha-docs-revision', content: BUILD_REVISION }]]
514+
: []
511515

512516
export default defineConfig({
513517
base: PUBLIC_BASE,
@@ -538,6 +542,7 @@ export default defineConfig({
538542
lastUpdated: true,
539543

540544
head: [
545+
...revisionHead,
541546
// Based on: https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs
542547
['link', { rel: 'icon', href: publicAsset('favicon.ico'), sizes: 'any' }],
543548
['link', { rel: 'icon', href: publicAsset('icon.svg'), sizes: 'image/svg+xml' }],

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ the backup through GitHub's official Pages actions. The repository Pages source
3333
must therefore be set to **GitHub Actions**; this workflow does not publish a
3434
`gh-pages` branch. Domain ownership and routing are managed in the hosting and
3535
DNS control planes, so do not add a checked-in `CNAME` file. The checked-in
36-
`vercel.json` runs the complete validation suite before every Vercel build.
36+
`vercel.json` runs the complete validation suite before every Vercel build. The
37+
deployment workflow also verifies that the canonical domain serves the exact
38+
`main` revision, renders the language selector, and exposes a translated locale
39+
before reporting success.
3740

3841
## Validation
3942

@@ -47,6 +50,7 @@ pnpm test
4750
pnpm validate
4851
pnpm build
4952
pnpm cli validate-links .vitepress/dist
53+
pnpm cli validate-locales .vitepress/dist
5054
```
5155

5256
`pnpm validate:i18n` requires every English route in all 20 maintained

etc/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import yargs from 'yargs'
22
import { hideBin } from 'yargs/helpers'
33
import { scanAndReport } from './validate-links'
4+
import { scanBuiltLocalesAndReport } from './validate-locales-build'
45

56
yargs(hideBin(process.argv))
67
.command(
@@ -17,5 +18,21 @@ yargs(hideBin(process.argv))
1718
})
1819
},
1920
)
21+
.command(
22+
'validate-locales <root>',
23+
'Verifies built locale roots and the language selector',
24+
(y) =>
25+
y
26+
.positional('root', { description: "Root directory of VitePress's output", type: 'string', demandOption: true })
27+
.option('public-path', { description: 'Public path used in locale links', default: '/', type: 'string' })
28+
.option('revision', { description: 'Expected deployment revision', default: null, type: 'string' }),
29+
async (opts) => {
30+
await scanBuiltLocalesAndReport({
31+
root: opts.root,
32+
publicPath: opts.publicPath,
33+
revision: opts.revision ?? process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.GITHUB_SHA,
34+
})
35+
},
36+
)
2037
.showHelpOnFail(false)
2138
.parse()

etc/validate-locales-build.spec.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
2+
import { tmpdir } from 'os'
3+
import path from 'path'
4+
import { describe, expect, test } from 'vitest'
5+
import { ALL_LOCALES, ROOT_LOCALE, type DocsLocale } from './locales'
6+
import { scanBuiltLocales } from './validate-locales-build'
7+
8+
function localeHref(locale: DocsLocale, publicPath: string): string {
9+
return `${publicPath}${locale.path ? `${locale.path}/` : ''}`
10+
}
11+
12+
function rootHtml(
13+
publicPath: string,
14+
options: { omittedLocale?: DocsLocale; revision?: string; wrongLabelLocale?: DocsLocale } = {},
15+
): string {
16+
const links = ALL_LOCALES.filter((locale) => locale !== ROOT_LOCALE)
17+
.filter((locale) => locale !== options.omittedLocale)
18+
.map(
19+
(locale) =>
20+
`<a href="${localeHref(locale, publicPath)}">${locale === options.wrongLabelLocale ? '' : locale.label}</a>`,
21+
)
22+
.join('')
23+
24+
const revision = options.revision
25+
? `<head><meta name="iroha-docs-revision" content="${options.revision}"></head>`
26+
: ''
27+
return `<html lang="en" dir="ltr">${revision}<body><div class="VPNavBarTranslations"><button aria-label="Change language"></button>${links}</div></body></html>`
28+
}
29+
30+
async function writeLocaleFixture(
31+
root: string,
32+
publicPath: string,
33+
options: { omittedLocale?: DocsLocale; revision?: string; wrongLabelLocale?: DocsLocale } = {},
34+
): Promise<void> {
35+
await Promise.all(
36+
ALL_LOCALES.map(async (locale) => {
37+
const directory = path.join(root, locale.path)
38+
await mkdir(directory, { recursive: true })
39+
const html =
40+
locale === ROOT_LOCALE
41+
? rootHtml(publicPath, options)
42+
: `<html lang="${locale.lang}" dir="${locale.direction}"><body></body></html>`
43+
await writeFile(path.join(directory, 'index.html'), html)
44+
}),
45+
)
46+
}
47+
48+
async function withFixture(
49+
publicPath: string,
50+
callback: (root: string) => Promise<void>,
51+
options: { omittedLocale?: DocsLocale; revision?: string; wrongLabelLocale?: DocsLocale } = {},
52+
): Promise<void> {
53+
const root = await mkdtemp(path.join(tmpdir(), 'iroha-docs-built-locales-'))
54+
try {
55+
await writeLocaleFixture(root, publicPath, options)
56+
await callback(root)
57+
} finally {
58+
await rm(root, { recursive: true, force: true })
59+
}
60+
}
61+
62+
describe('built locale validation', () => {
63+
test.each([
64+
{ option: undefined, builtPath: '/' },
65+
{ option: '/iroha-docs/', builtPath: '/iroha-docs/' },
66+
{ option: '/iroha-docs', builtPath: '/iroha-docs/' },
67+
{ option: 'iroha-docs', builtPath: '/iroha-docs/' },
68+
])('accepts a complete language selector for public path $option', async ({ option, builtPath }) => {
69+
await withFixture(builtPath, async (root) => {
70+
expect(await scanBuiltLocales({ root, publicPath: option })).toEqual([])
71+
})
72+
})
73+
74+
test('reports a missing language selector', async () => {
75+
await withFixture('/', async (root) => {
76+
await writeFile(path.join(root, 'index.html'), '<html lang="en" dir="ltr"><body></body></html>')
77+
expect(await scanBuiltLocales({ root })).toContain('English locale index does not render the language selector')
78+
})
79+
})
80+
81+
test('accepts the expected deployment revision', async () => {
82+
await withFixture(
83+
'/',
84+
async (root) => {
85+
expect(await scanBuiltLocales({ root, revision: 'abc123' })).toEqual([])
86+
},
87+
{ revision: 'abc123' },
88+
)
89+
})
90+
91+
test('reports a stale deployment revision', async () => {
92+
await withFixture(
93+
'/',
94+
async (root) => {
95+
expect(await scanBuiltLocales({ root, revision: 'new-sha' })).toContain(
96+
'built revision is old-sha; expected new-sha',
97+
)
98+
},
99+
{ revision: 'old-sha' },
100+
)
101+
})
102+
103+
test('reports a missing locale link', async () => {
104+
const japanese = ALL_LOCALES.find((locale) => locale.key === 'ja')!
105+
await withFixture(
106+
'/',
107+
async (root) => {
108+
expect(await scanBuiltLocales({ root })).toContain('language selector is missing 日本語: /ja/')
109+
},
110+
{ omittedLocale: japanese },
111+
)
112+
})
113+
114+
test('reports an empty language label', async () => {
115+
const french = ALL_LOCALES.find((locale) => locale.key === 'fr')!
116+
await withFixture(
117+
'/',
118+
async (root) => {
119+
expect(await scanBuiltLocales({ root })).toContain(
120+
'language selector link /fr/ is labelled (empty); expected Français',
121+
)
122+
},
123+
{ wrongLabelLocale: french },
124+
)
125+
})
126+
127+
test('reports a missing accessible button label', async () => {
128+
await withFixture('/', async (root) => {
129+
const html = rootHtml('/').replace(' aria-label="Change language"', '')
130+
await writeFile(path.join(root, 'index.html'), html)
131+
expect(await scanBuiltLocales({ root })).toContain(
132+
'language selector is missing a non-empty accessible button label',
133+
)
134+
})
135+
})
136+
137+
test('reports a missing locale root once', async () => {
138+
const japanese = ALL_LOCALES.find((locale) => locale.key === 'ja')!
139+
await withFixture('/', async (root) => {
140+
await rm(path.join(root, japanese.path, 'index.html'))
141+
const issues = await scanBuiltLocales({ root })
142+
expect(issues.filter((issue) => issue.includes('missing 日本語 locale index'))).toHaveLength(1)
143+
})
144+
})
145+
146+
test('reports incorrect language metadata', async () => {
147+
const japanese = ALL_LOCALES.find((locale) => locale.key === 'ja')!
148+
await withFixture('/', async (root) => {
149+
await writeFile(path.join(root, japanese.path, 'index.html'), '<html lang="en" dir="ltr"></html>')
150+
expect(await scanBuiltLocales({ root })).toContain('日本語 locale index has lang=en; expected ja')
151+
})
152+
})
153+
154+
test('reports a missing html element', async () => {
155+
const japanese = ALL_LOCALES.find((locale) => locale.key === 'ja')!
156+
await withFixture('/', async (root) => {
157+
await writeFile(path.join(root, japanese.path, 'index.html'), '<p>broken</p>')
158+
expect(await scanBuiltLocales({ root })).toContain('日本語 locale index has no html element')
159+
})
160+
})
161+
162+
test('reports incorrect right-to-left metadata', async () => {
163+
const arabic = ALL_LOCALES.find((locale) => locale.key === 'ar')!
164+
await withFixture('/', async (root) => {
165+
await writeFile(path.join(root, arabic.path, 'index.html'), '<html lang="ar" dir="ltr"></html>')
166+
expect(await scanBuiltLocales({ root })).toContain('العربية locale index has dir=ltr; expected rtl')
167+
})
168+
})
169+
})

0 commit comments

Comments
 (0)