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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
# No private planning document and no private identifier, as in uptide-dev/uptide.
# The denylist is the UPTIDE_PRIVATE_DENYLIST secret; scripts/private-material.mjs
# decides which scan runs on which event.
- name: No private material
env:
UPTIDE_PRIVATE_DENYLIST: ${{ secrets.UPTIDE_PRIVATE_DENYLIST }}
EVENT: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO: ${{ github.repository }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: node scripts/private-material.mjs
- name: The page's own checks
run: node --test 'test/*.test.mjs'
47 changes: 47 additions & 0 deletions .github/workflows/dco.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: DCO

on:
pull_request:

permissions:
contents: read

jobs:
signed-off:
runs-on: ubuntu-latest
steps:
# The checker runs from the pull request's own checkout, as the rest of CI does.
# Taking it from the base branch instead would buy nothing: for a `pull_request`
# event GitHub already reads the workflow definition from the pull request, so the
# step invoking the checker is the pull request's either way -- and the base branch
# does not have the checker at all until this lands. What does the work is the
# event: `pull_request`, never `pull_request_target`, so the job holds a read-only
# token and no secret, whoever opened it. The range below comes from this
# repository's own refs rather than from anything the branch says about itself.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Every commit is signed off
# Values from the event go through the environment, never into the script source.
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
# Who opened the pull request, as GitHub records it: a bot's own commits in its
# own pull request (Dependabot) are exempt; a commit merely claiming a bot's
# address in someone else's pull request is not. See BOTS in scripts/dco.mjs.
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# refs/pull/N/head is the complete pull request head as this repository sees it,
# for a branch and for a fork alike, and refs/heads/<base> is the base branch as
# it stands now. Both are fetched here rather than assumed, with full history, so
# the merge base -- and so the whole range the pull request adds -- resolves.
git fetch --no-tags origin \
"+refs/pull/${PR_NUMBER}/head:refs/uptide/pr-head" \
"+refs/heads/${BASE_REF}:refs/uptide/base"
# The refs above are CI's own bookkeeping: --base-name is the branch a
# contributor would actually rebase onto, for the failure message.
node scripts/dco.mjs refs/uptide/base refs/uptide/pr-head \
--base-name="origin/${BASE_REF}" --pr-author="${PR_AUTHOR}"
47 changes: 47 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Pages

# Publishes site/ to GitHub Pages as it is, with no build step: on a push to main that
# changes it, or by hand. See site/README.md.
on:
push:
branches: [main]
paths: ['site/**', '.github/workflows/pages.yml']
workflow_dispatch:

concurrency:
group: pages
cancel-in-progress: false

permissions: {}

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
# checkout reads the repository; deploy-pages publishes with the OIDC identity.
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
env:
# The page's own address, for its canonical and social-preview URLs. The one value to
# change when the page moves to a domain.
SITE_URL: https://uptide-dev.github.io/
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Write the site URL into the page
run: |
sed -i "s#%SITE_URL%#${SITE_URL}#g" site/index.html
if grep -q '%SITE_URL%' site/index.html; then
echo "::error::site/index.html still has a %SITE_URL% placeholder."
exit 1
fi
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,43 @@

The landing page of [Uptide](https://github.com/uptide-dev/uptide), published at
https://uptide-dev.github.io.

The page is `site/`: static HTML with inline CSS, self-hosted fonts and no build step. What
each file is, and where every number on the page comes from: [site/README.md](site/README.md).

## See it locally

```sh
python3 -m http.server 8000 --directory site # then open http://localhost:8000
```

`%SITE_URL%` in the page's `<head>` is expected: the deploy writes the real address there
(canonical and social-preview URLs only; nothing visible).

## Publishing

`.github/workflows/pages.yml` deploys `site/` to GitHub Pages on every push to `main` that
changes it. Its `SITE_URL` is the one value to change if the page moves to a domain.

One-time setup:

1. The repository is **public** (Pages on a private repository needs a paid plan, and the page
is public anyway).
2. Settings → Pages → Source: **GitHub Actions**.
3. Settings → Secrets and variables → Actions → secret **`UPTIDE_PRIVATE_DENYLIST`**, the same
value as in uptide-dev/uptide.

## Checks

- **DCO**: every commit is signed off (`git commit -s`), as in uptide-dev/uptide.
- **No private material**: no private planning document and no identifier from the denylist.
- **The page's own tests** (`node --test 'test/*.test.mjs'`): every font next to its license,
the URLs from `SITE_URL`, nothing loaded from another origin, the deploy's trigger and
permissions.

`scripts/` (`dco.mjs`, `bots.mjs`, `public-tree.mjs`, `private-material.mjs`) and
`.github/workflows/dco.yml` are copies from uptide-dev/uptide, where they are tested. Change
them there first, then copy them here.

The page is licensed under Apache-2.0 ([LICENSE](LICENSE)); its fonts under the SIL Open Font
License 1.1, with each license next to its font in `site/fonts/`.
Binary file added docs/screenshots/site-landing/desktop-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/desktop-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/mobile-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/mobile-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions scripts/bots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The bots that open pull requests here, by the login GitHub records as a pull request's
// opener: a contributor cannot set it, so it is what every exemption keys on.

/** The GitHub App that opens the Version Packages pull request (docs/releasing.md). */
export const RELEASE_APP = 'uptide-release';
export const RELEASE_BOT = `${RELEASE_APP}[bot]`;
export const DEPENDABOT = 'dependabot[bot]';

/**
* Each bot's commit address. A bot's own commits in its own pull request need no sign-off
* (scripts/dco.mjs). An app's user id is assigned when it is created, so its address is
* matched whatever the id: the opener, which only the app can be, is what is checked.
*/
export const BOTS = {
[DEPENDABOT]: '49699333+dependabot[bot]@users.noreply.github.com',
'github-actions[bot]': '41898282+github-actions[bot]@users.noreply.github.com',
[RELEASE_BOT]: /^\d+\+uptide-release\[bot\]@users\.noreply\.github\.com$/,
};
125 changes: 125 additions & 0 deletions scripts/dco.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Whether every commit a pull request adds carries a Developer Certificate of Origin
// sign-off (https://developercertificate.org/), which is how a contributor states they
// have the right to send the patch under the project's license.
//
// node scripts/dco.mjs <base-ref> <head-ref> [--base-name=<name>] [--pr-author=<login>] [--json]
//
// The range is everything on `head` since it left `base`, so a long-running branch is not
// asked to account for commits it merely inherited. CI passes the refs it fetched for
// itself, which are not names a contributor could rebase onto, so `--base-name` sets what
// the failure message tells them to use.
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { BOTS } from './bots.mjs';

/**
* Bots that cannot sign off, by login, with the noreply address GitHub gives each
* (scripts/bots.mjs). A commit is exempt only when the pull request was opened by that bot
* (`--pr-author`, which GitHub sets and a contributor cannot) and the commit is authored by
* that same bot: an author address is just text in a commit, so on its own it would let
* anyone skip the sign-off.
*/
export { BOTS };

/** The commits a bot's own pull request carries in its own name. */
export function exempt(list, prAuthor) {
const bot = Object.hasOwn(BOTS, prAuthor ?? '') ? BOTS[prAuthor] : undefined;
if (!bot) return [];
const own = (email) => (bot instanceof RegExp ? bot.test(email) : email === bot);
return list.filter((c) => own(c.email.toLowerCase()));
}

/** A sign-off line: a name, then an address in angle brackets, as `git commit -s` writes. */
const SIGN_OFF = /^[ \t]*Signed-off-by:[ \t]*(\S.*?)[ \t]*<([^<>\s]+@[^<>\s]+)>[ \t]*$/gim;

const git = (args, cwd) =>
execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });

/** The commit `head` adds on top of `base`: oldest first, merge commits included. */
export function commits(base, head, cwd = process.cwd()) {
// A pull request's base branch moves on. What the branch added is measured from where it
// forked off, which is the merge base, not wherever the base branch has since got to.
const from = git(['merge-base', base, head], cwd).trim();
// One record per commit, with field and record separators no commit message can contain.
const log = git(
['log', '--reverse', '--format=%H%x1f%ae%x1f%s%x1f%B%x1e', `${from}..${head}`],
cwd,
);
return log
.split('\x1e')
.map((record) => record.replace(/^\n/, ''))
.filter(Boolean)
.map((record) => {
const [sha, email, subject, message] = record.split('\x1f');
return { sha, email, subject, message };
});
}

/**
* Everything missing, as sentences. A sign-off has to be the commit author's own: a line
* naming somebody else certifies nothing about the person who wrote the patch.
*/
export function problems(list, prAuthor) {
const found = [];
const skip = new Set(exempt(list, prAuthor));
for (const commit of list) {
if (skip.has(commit)) continue;
const { sha, email, subject, message } = commit;
const short = sha.slice(0, 8);
const signers = [...message.matchAll(SIGN_OFF)].map((match) => match[2].toLowerCase());
if (!signers.length) found.push(`${short} has no Signed-off-by line: ${subject}`);
else if (!signers.includes(email.toLowerCase()))
found.push(
`${short} is signed off by ${signers.join(', ')} but authored by ${email}: ${subject}`,
);
}
return found;
}

/** What to run to fix it, naming the branch this pull request is against. */
export function howToFix(base) {
return [
'Every commit in a pull request needs a Developer Certificate of Origin sign-off.',
'Add it to the commits you already pushed, then force-push the branch:',
'',
` git rebase --signoff ${base}`,
' git push --force-with-lease',
'',
'A merge commit needs one too (`git merge --signoff`); rebasing avoids them entirely.',
'New commits get the line from `git commit -s`. See CONTRIBUTING.md.',
].join('\n');
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const [base, head] = args.filter((arg) => !arg.startsWith('--'));
if (!base || !head) {
console.error(
'usage: node scripts/dco.mjs <base-ref> <head-ref> [--base-name=<name>] [--pr-author=<login>] [--json]',
);
process.exit(2);
}
const list = commits(base, head);
const prAuthor = args.find((arg) => arg.startsWith('--pr-author='))?.slice(12);
const bots = exempt(list, prAuthor);
const found = problems(list, prAuthor);
// The ref CI fetched is not a name anyone can rebase onto; this says what to print.
const named = args.find((arg) => arg.startsWith('--base-name='))?.slice(12) || base;
if (args.includes('--json')) {
console.log(
JSON.stringify(
{ commits: list.map((c) => c.sha), exempt: bots.map((c) => c.sha), problems: found },
null,
2,
),
);
} else {
console.log(`${list.length} commits on ${head} since ${named}`);
for (const c of bots)
console.log(` · ${c.sha.slice(0, 8)} authored by ${prAuthor}, exempt: ${c.subject}`);
for (const line of found) console.log(` ✗ ${line}`);
console.log(found.length ? `\n${howToFix(named)}` : 'every commit is signed off');
}
process.exit(found.length ? 1 : 0);
}
45 changes: 45 additions & 0 deletions scripts/private-material.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// How CI scans for private material (scripts/public-tree.mjs) on this event. The denylist
// is a secret: required on pushes and on pull requests from this repository. A fork has no
// secrets, so only document names are checked there. Dependabot's own pull requests get no
// repository secrets either, and are never given this one (CONTRIBUTING.md): with the
// opener GitHub records as dependabot[bot] and no denylist, the identifier scan is skipped
// with a notice, and whatever it brings is scanned with the secret on its push to main.
// Every other opener, the release app included (its pull requests get secrets), is scanned
// in full, and a missing denylist fails.
//
// EVENT, HEAD_REPO, REPO, PR_AUTHOR, UPTIDE_PRIVATE_DENYLIST node scripts/private-material.mjs
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEPENDABOT } from './bots.mjs';
import { DENYLIST_ENV } from './public-tree.mjs';

/** The public-tree arguments for this event, and the notice to print when it skips a part. */
export function scanFor({ event, headRepo, repo, prAuthor, denylist }) {
if (event === 'pull_request' && headRepo !== repo) return { args: ['.'] };
if (event === 'pull_request' && prAuthor === DEPENDABOT && !denylist)
return {
args: ['.'],
notice:
'Private identifier scan skipped: Dependabot pull requests get no repository secrets, and this one is never given to them. Document names are still checked; the merge to main is scanned with the denylist.',
};
return { args: ['.', '--require-denylist'] };
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const env = process.env;
const { args, notice } = scanFor({
event: env.EVENT,
headRepo: env.HEAD_REPO,
repo: env.REPO,
prAuthor: env.PR_AUTHOR,
denylist: env[DENYLIST_ENV],
});
if (notice) console.log(`::notice title=No private material::${notice}`);
const scan = spawnSync(
process.execPath,
[fileURLToPath(new URL('./public-tree.mjs', import.meta.url)), ...args],
{ stdio: 'inherit' },
);
process.exit(scan.status ?? 1);
}
Loading
Loading