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
2 changes: 1 addition & 1 deletion .github/workflows/changelog-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ jobs:

- name: Install GitHub Copilot CLI
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
run: npm install -g @github/copilot
run: npm install -g @github/copilot@1.0.71

- name: Generate changelog draft via Copilot
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
Expand Down
29 changes: 24 additions & 5 deletions .github/workflows/purge-fastly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ name: Purge Fastly

on:
deployment_status:
schedule:
# Daily soft purge of all languages to catch reusables and AUTOTITLE links.
# Intentionally off-peak, around 6-7pm PT.
- cron: '20 2 * * *'
workflow_dispatch:
inputs:
languages:
Expand Down Expand Up @@ -58,6 +62,17 @@ jobs:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Generate GitHub App token
# Lets a scheduled-run failure open a tracking issue in docs-engineering
# (the default GITHUB_TOKEN can't write cross-repo).
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.DOCS_BOT_APP_ID }}
private-key: ${{ secrets.DOCS_BOT_APP_PRIVATE_KEY }}
owner: github
repositories: docs-engineering

- uses: ./.github/actions/node-npm-setup

- name: Validate confirmation input
Expand All @@ -76,8 +91,9 @@ jobs:

- name: Purge Fastly
# Auto post-deploy runs wait for the build, purge English only (temporary),
# and stay soft. A manual run uses the inputs: blank languages = all, the
# `hard` toggle, or a confirmed full-cache purge.
# and stay soft. The daily scheduled run waits for nothing, purges all
# languages (blank = all), and stays soft. A manual run uses the inputs:
# blank languages = all, the `hard` toggle, or a confirmed full-cache purge.
#
# Raw inputs are passed through the environment and quoted, never spliced
# into the command string, so a value like `en' --everything` can't break
Expand All @@ -89,10 +105,8 @@ jobs:
EVERYTHING_INPUT: ${{ inputs.everything }}
run: |
args=()
if [ "$EVENT_NAME" != "workflow_dispatch" ]; then
args+=(--wait-for-build)
fi
if [ "$EVENT_NAME" = "deployment_status" ]; then
args+=(--wait-for-build)
args+=(--languages en)
elif [ -n "$LANGUAGES_INPUT" ]; then
args+=(--languages "$LANGUAGES_INPUT")
Expand Down Expand Up @@ -123,3 +137,8 @@ jobs:
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
with:
slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}

- uses: ./.github/actions/create-workflow-failure-issue
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
with:
token: ${{ steps.app-token.outputs.token }}
75 changes: 75 additions & 0 deletions src/fixtures/tests/playwright-rendering.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,81 @@ test.describe('test nav at different viewports', () => {
})
})

test.describe('secondary-bar breadcrumb scroller', () => {
// The secondary bar (and its breadcrumb scroller) only renders at wide
// viewports, and the fixture trail is short enough to fit there, so we cap the
// scroller width to force a deterministic overflow independent of title
// lengths — then exercise the chevrons.
test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => {
// Smooth-scroll settle waits across several chevron clicks add up past the
// default 5s cap.
test.setTimeout(20000)
page.setViewportSize({ width: 1300, height: 700 })
await page.goto('/get-started/foo/bar')

const bar = page.getByTestId('breadcrumbs-bar')
await expect(bar).toBeVisible()

const scrollArea = page.locator('[data-search="breadcrumbs"]')
await expect(scrollArea).toBeVisible()

// Force a deterministic overflow independent of title lengths: cap the
// scroll region, drop the nav's min-width:100% (which otherwise stretches the
// short fixture trail to fill the container so it never overflows), and pad
// the crumbs so several are hidden at once — enough that a per-crumb nudge is
// distinguishable from a jump to the end.
await page.addStyleTag({
content: `
[data-search="breadcrumbs"] { max-width: 360px; }
[data-search="breadcrumbs"] nav { min-width: 0 !important; }
[data-search="breadcrumbs"] li { padding-right: 60px; }
`,
})

const scrollLeftOf = () => scrollArea.evaluate((el) => el.scrollLeft)
const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth)
await expect.poll(maxScrollOf).toBeGreaterThan(0)

// Anchor to the right end explicitly so we start from a known state: fully
// scrolled right (current page visible), only the left chevron active.
await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' }))
const maxScroll = await maxScrollOf()
await expect.poll(scrollLeftOf).toBe(maxScroll)

const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' })
const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' })
// At the right extreme the left chevron is active and the right one is hidden.
await expect(leftChevron).toBeVisible()
await expect(rightChevron).toBeHidden()

// One left click nudges toward the start by a single crumb — it must move,
// but must NOT jump all the way to 0 (the old behavior) while more than one
// crumb is still hidden to the left.
await leftChevron.click()
await expect.poll(scrollLeftOf).toBeLessThan(maxScroll)
const afterOneLeft = await scrollLeftOf()
expect(afterOneLeft).toBeGreaterThan(0)
// The right chevron appears once we're no longer at the right extreme.
await expect(rightChevron).toBeVisible()

// A right click walks back toward the current page by one crumb, not a full
// jump back to the right extreme.
await rightChevron.click()
await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft)

// Repeated left clicks eventually reach the start, which hides the left
// chevron (canScrollLeft flips false). Drive off the chevron's own visibility
// rather than an exact scrollLeft, since smooth scrolling can leave a
// sub-pixel remainder.
for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) {
await leftChevron.click()
await page.waitForTimeout(200)
}
await expect(leftChevron).toBeHidden()
await expect.poll(scrollLeftOf).toBeLessThanOrEqual(1)
})
})

test.describe('survey', () => {
test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled')

Expand Down
18 changes: 18 additions & 0 deletions src/frame/components/page-header/BreadcrumbsScroller.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@
background-color: var(--bgColor-default, var(--color-canvas-default));
}

// On hover, keep the solid canvas background (Primer's invisible IconButton
// otherwise swaps in a translucent tint, letting crumbs bleed through the
// button) and instead signal interactivity by bolding the chevron: a
// currentColor stroke thickens the fill-based octicon glyph.
.leftChevron:hover,
.rightChevron:hover {
background-color: var(
--bgColor-default,
var(--color-canvas-default)
) !important;
color: var(--fgColor-default, var(--color-fg-default));

svg {
stroke: currentColor;
stroke-width: 0.75px;
}
}

// Hidden chevrons are removed from view and interaction (and from the tab
// order / a11y tree in the component). Because they're absolutely positioned
// they already occupy no layout space, so the scroll area is unaffected.
Expand Down
38 changes: 32 additions & 6 deletions src/frame/components/page-header/BreadcrumbsScroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { Breadcrumbs } from './Breadcrumbs'

import styles from './BreadcrumbsScroller.module.scss'

// Padding past the ~28px chevron so a revealed crumb clears it rather than
// landing half-hidden underneath.
const CHEVRON_PAD = 32

// Wraps the secondary-bar breadcrumbs in a horizontal scroll region. When the
// trail is too long to fit, the region scrolls — anchored to the RIGHT so the
// current page is shown first — with a left chevron to reveal the leftmost
Expand Down Expand Up @@ -67,20 +71,42 @@ export const BreadcrumbsScroller = () => {
return () => observer.disconnect()
}, [anchorRight])

// Reveal the start of the trail (Home) in one click. Breadcrumb trails are
// short, so scrolling fully to the left is clearer than a partial nudge that
// can leave the leading crumb half-hidden behind the chevron.
// Reveal the previous crumb: align the crumb straddling the left edge to that
// edge. Targeting the boundary crumb (not stepping back from the first fully
// visible one) also covers a current crumb wider than the viewport. Falls back
// to the far left only when nothing is clipped left.
const scrollLeftClick = useCallback(() => {
const el = scrollRef.current
if (!el) return
el.scrollTo({ left: 0 })
const containerLeft = el.getBoundingClientRect().left + CHEVRON_PAD
const crumbs = Array.from(el.querySelectorAll('li'))
// The rightmost crumb still clipped/off to the left: the one to reveal.
let target: Element | undefined
for (const crumb of crumbs) {
if (crumb.getBoundingClientRect().left < containerLeft - 1) target = crumb
}
if (!target) {
el.scrollTo({ left: 0 })
return
}
el.scrollBy({ left: target.getBoundingClientRect().left - containerLeft })
}, [])

// Return to the current page (the end of the trail) in one click.
// Reveal the next crumb toward the current page. Mirrors scrollLeftClick,
// including the oversized-crumb case. Falls back to the far right only when
// nothing is clipped right.
const scrollRightClick = useCallback(() => {
const el = scrollRef.current
if (!el) return
el.scrollTo({ left: el.scrollWidth })
const containerRight = el.getBoundingClientRect().right - CHEVRON_PAD
const crumbs = Array.from(el.querySelectorAll('li'))
// The leftmost crumb still clipped/off to the right: the one to reveal.
const target = crumbs.find((crumb) => crumb.getBoundingClientRect().right > containerRight + 1)
if (!target) {
el.scrollTo({ left: el.scrollWidth })
return
}
el.scrollBy({ left: target.getBoundingClientRect().right - containerRight })
}, [])

// When a crumb link receives focus via keyboard (Tab), the browser does not
Expand Down
27 changes: 18 additions & 9 deletions src/landings/components/shared/LandingHero.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,24 @@
}
}

// Where the art IS shown, reserve a right-side gutter so the text column wraps
// before it reaches the artwork. Without this, the 48rem text block overlaps
// the right-anchored isometric art in the ~866–1130px band, dropping the muted
// intro to ~1.2:1 over the orange/pink fill (WCAG 1.4.3 AA fail). The art is
// pinned to the border box (background-origin above), so this padding insets
// only the text, not the artwork. 22rem clears the opaque art (~300px) plus a
// gap at every width in the band.
// Where the art IS shown, the intro can extend far enough right to overlap the
// right-anchored isometric art (the heading is short enough that it never
// does). Instead of reserving a right gutter that shrinks the text column, put
// a frosted-glass panel behind the intro so it stays readable over the art
// matching the homepage hero treatment (HomePageHero.module.scss `.content`).
// Guarded to >=866px because the art is hidden below that, so no panel is
// needed on mobile.
@media (min-width: 866px) {
.landingHero {
padding-right: 22rem;
.heroIntroScrim {
display: inline-block;
backdrop-filter: blur(1rem);
background-color: color-mix(
in srgb,
var(--color-canvas-default) 70%,
transparent
);
border-radius: var(--brand-borderRadius-medium, 0.75rem);
padding: 0.5rem 0.75rem;
margin: -0.5rem -0.75rem;
}
}
7 changes: 6 additions & 1 deletion src/landings/components/shared/LandingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHero
{title}
</Heading>
{intro && (
<Text as="div" size="200" variant="muted" className={styles.heroDescription}>
<Text
as="div"
size="200"
variant="muted"
className={`${styles.heroDescription} ${styles.heroIntroScrim}`}
>
<RenderedHTML as="div" html={intro} />
</Text>
)}
Expand Down
34 changes: 29 additions & 5 deletions src/workflows/purge-fastly-changed-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Octokit } from '@octokit/rest'

import { fetchWithRetry } from '@/frame/lib/fetch-utils'
import warmServer from '@/frame/lib/warm-server'
import { allVersions } from '@/versions/lib/all-versions'
import github from './github'
import { getActionContext } from './action-context'

Expand All @@ -19,13 +20,31 @@ import { getActionContext } from './action-context'
// fresh. By the time deployment succeeds, the old pods are already terminated
// (Heaven waits for the rollout to fully reconcile), so this is deterministic.
//
// Scope: content/ only. data/ changes (reusables, variables, ...) fan out to
// far too many URLs to enumerate cheaply, so they stay covered by the soft
// purge of the whole language.
// Scope: content/ only, and only the latest release of each plan (fpt, ghec,
// latest ghes — see PURGEABLE_PAGE_VERSIONS). data/ changes (reusables,
// variables, ...) fan out to far too many URLs to enumerate cheaply, so they
// stay covered by the soft purge of the whole language.

const PROD_HOST = 'docs.github.com'
const CONTENT_PREFIX = 'content/'

// Which page versions we hard-purge. A page that applies to every
// version fans out to one URL per version: fpt + ghec + one per *supported* GHES
// release (8+ URLs today, growing with each GHES release). Enumerating all of
// them for a large deploy blew past Fastly's URL-purge rate limit (HTTP 429) and
// failed the job. So we only target the newest ("latest") release of each plan:
// free-pro-team (fpt), enterprise-cloud (ghec), and the newest supported
// enterprise-server (ghes). Older still-supported GHES releases change far less
// often and remain covered by the routine soft purge of the whole `language:en`
// key that always runs. A version is the latest of its plan exactly when its
// `version` equals its plan's `latestVersion`, so this set is {fpt, ghec, latest
// ghes} and updates itself as GHES releases come and go.
export const PURGEABLE_PAGE_VERSIONS = new Set(
Object.values(allVersions)
.filter((version) => version.version === version.latestVersion)
.map((version) => version.version),
)

// A defensive ceiling. A normal content deploy touches a handful of files ->
// tens of URLs. If a deploy changes so much that we'd hard-purge more than this,
// skip the targeted purge: the soft purge of the whole `language:en` key (which
Expand Down Expand Up @@ -216,14 +235,19 @@ export function contentFilesToEnglishUrls(
return [...urls]
}

// Build relativePath -> English permalink hrefs from the warmed page list.
// Build relativePath -> English permalink hrefs from the warmed page list. Only
// the latest release of each plan is kept (see PURGEABLE_PAGE_VERSIONS) to cap
// the URL fan-out that was tripping Fastly's purge rate limiter.
async function loadEnglishPermalinkIndex(): Promise<Map<string, string[]>> {
const { pageList } = await warmServer(['en'])
const index = new Map<string, string[]>()
for (const page of pageList) {
if (page.languageCode !== 'en') continue
const hrefs = page.permalinks
.filter((permalink) => permalink.languageCode === 'en')
.filter(
(permalink) =>
permalink.languageCode === 'en' && PURGEABLE_PAGE_VERSIONS.has(permalink.pageVersion),
)
.map((permalink) => permalink.href)
if (hrefs.length) {
index.set(page.relativePath, hrefs)
Expand Down
Loading
Loading