diff --git a/content/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions.md b/content/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions.md index e44f3a3277b8..2be224a4b6b2 100644 --- a/content/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions.md +++ b/content/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions.md @@ -66,9 +66,51 @@ An action's listing page includes the action's version and the workflow syntax r ### Adding an action from the same repository -If an action is defined in the same repository where your workflow file uses the action, you can reference the action with either the ‌`{owner}/{repo}@{ref}` or `./path/to/dir` syntax in your workflow file. +If an action is defined in the same repository where your workflow file uses the action, you can reference the action with the `$/path/to/dir` self repository reference, or with the `{owner}/{repo}@{ref}` or `./path/to/dir` syntax in your workflow file. The `$/` syntax is not available in {% data variables.product.prodname_ghe_server %}. + +Example repository file structure: + +```shell +|-- hello-world (repository) +| |__ .github +| └── workflows +| └── my-first-workflow.yml +| └── actions +| |__ hello-world-action +| └── action.yml +``` + +We recommend referencing the action with the `$/path/to/dir` self repository reference. This resolves to the same repository at the running commit, so you do not need to check out the repository first. For more information about how `$/` compares to `{owner}/{repo}@{ref}` and `./`, see [AUTOTITLE](/actions/reference/workflows-and-actions/workflow-syntax#example-using-an-action-in-the-same-repository-as-the-workflow-at-the-running-commit-recommended). + +Example workflow file using `$/`: + +```yaml +jobs: + my_first_job: + runs-on: ubuntu-latest + steps: + # This step references an action in the same repository at the + # running commit. No repository checkout is required. + - name: Use hello-world-action + uses: $/.github/actions/hello-world-action +``` + +You can also reference the action with the relative `./path/to/dir` syntax, but it is more error-prone. The path is relative (`./`) to the default working directory (`github.workspace`, `$GITHUB_WORKSPACE`), so it requires a checkout step, and if the action checks out the repository to a location different than the workflow, the relative path must be updated. -{% data reusables.actions.workflows.section-referencing-an-action-from-the-same-repository %} +Example workflow file using `./`: + +```yaml +jobs: + my_first_job: + runs-on: ubuntu-latest + steps: + # This step checks out a copy of your repository. + - name: My first step - check out repository + uses: {% data reusables.actions.action-checkout %} + # This step references the directory that contains the action. + - name: Use local hello-world-action + uses: ./.github/actions/hello-world-action +``` The `action.yml` file is used to provide metadata for the action. Learn about the content of this file in [AUTOTITLE](/actions/reference/workflows-and-actions/metadata-syntax). diff --git a/content/actions/reference/workflows-and-actions/metadata-syntax.md b/content/actions/reference/workflows-and-actions/metadata-syntax.md index 03b372a8d5df..bf065b758769 100644 --- a/content/actions/reference/workflows-and-actions/metadata-syntax.md +++ b/content/actions/reference/workflows-and-actions/metadata-syntax.md @@ -339,6 +339,8 @@ runs: - uses: actions/checkout@main # References a subdirectory in a public GitHub repository at a specific branch, ref, or SHA - uses: actions/aws/ec2@main + # References an action in the same repository at the running commit + - uses: $/.github/actions/my-action # References a local action - uses: ./.github/actions/my-action # References a docker public registry action @@ -347,6 +349,10 @@ runs: - uses: docker://alpine:3.8 ``` +To reference an action stored in the same repository as your composite action, use the `$/` self repository reference, as shown in the `$/.github/actions/my-action` example above. It resolves to that repository at the running commit, so you do not need to check out the repository first, and it must not include an `@{ref}` suffix. The `$/` syntax is not available in {% data variables.product.prodname_ghe_server %}. + +For a comparison of `$/`, `{owner}/{repo}@{ref}`, and `./`, see [AUTOTITLE](/actions/reference/workflows-and-actions/workflow-syntax#example-using-an-action-in-the-same-repository-as-the-workflow-at-the-running-commit-recommended). + #### `runs.steps[*].with` **Optional** A `map` of the input parameters defined by the action. Each input parameter is a key/value pair. For more information, see [Example: Specifying inputs](#example-specifying-inputs). diff --git a/content/actions/reference/workflows-and-actions/workflow-syntax.md b/content/actions/reference/workflows-and-actions/workflow-syntax.md index e6bd837aea5a..1d4a98add51b 100644 --- a/content/actions/reference/workflows-and-actions/workflow-syntax.md +++ b/content/actions/reference/workflows-and-actions/workflow-syntax.md @@ -597,11 +597,42 @@ jobs: uses: actions/aws/ec2@main ``` +### Example: Using an action in the same repository as the workflow at the running commit (recommended) + +`$/path/to/action` + +The `$/` prefix is the self repository reference. It references an action stored in the same repository as the workflow or action that is currently running, and resolves to that repository at the running commit (the same SHA as the running workflow or action). You do not need to check out the repository first, so it is the recommended way to reference an action within its own repository. + +The `$/` syntax is not available in {% data variables.product.prodname_ghe_server %}. + +A `$/` reference must not include an `@{ref}` suffix. The ref is always the commit the running workflow or action is using, so a reference such as `$/actions/my-action@v1` is invalid. + +`$/` always resolves against the repository of the file it appears in, not the repository that called it. For example, if a reusable workflow in one repository is called by a workflow in another repository, a `$/` reference in the called workflow resolves to the called workflow's repository, not the calling workflow's repository. This makes `$/` reliable for action composition, where a relative `./` path would instead resolve against whatever is checked out in the caller's workspace. For using `$/` in a composite action's steps, see [AUTOTITLE](/actions/reference/workflows-and-actions/metadata-syntax#runsstepsuses). + +The following table compares the ways to reference an action. + +| Syntax | Resolves to | Recommended for | +| ------ | ----------- | --------------- | +| `$/path/to/action` | The same repository as the running workflow or action, at the running commit | Actions in the same repository | +| `{owner}/{repo}@{ref}` | The specified repository at the specified ref | Actions in another repository | +| `./path/to/action` | A path in the runner's checked-out workspace, relative to the default working directory (`{% raw %}${{ github.workspace }}{% endraw %}`) | Edge cases only | + +```yaml +on: [push] + +jobs: + my_first_job: + runs-on: ubuntu-latest + steps: + # References an action in the same repository at the running commit + - uses: $/.github/actions/hello-world-action +``` + ### Example: Using an action in the same repository as the workflow `./path/to/dir` -The path to the directory that contains the action in your workflow's repository. You must check out your repository before using the action. +The path to the directory that contains the action in your workflow's repository. You must check out your repository before using the action, and the `./` path resolves against the runner's workspace rather than the repository of the running workflow. For most cases, use the `$/` syntax shown above instead. {% data reusables.actions.workflows.section-referencing-an-action-from-the-same-repository %} diff --git a/data/reusables/actions/allow-specific-actions-intro.md b/data/reusables/actions/allow-specific-actions-intro.md index 471201231554..744cf7edb5f3 100644 --- a/data/reusables/actions/allow-specific-actions-intro.md +++ b/data/reusables/actions/allow-specific-actions-intro.md @@ -3,7 +3,7 @@ ### Allowing select actions{% ifversion actions-workflow-policy %} and reusable workflows{% endif %} to run -When you choose {% data reusables.actions.policy-label-for-select-actions-workflows %}, local actions{% ifversion actions-workflow-policy %} and reusable workflows{% endif %} are allowed, and there are additional options for allowing other specific actions{% ifversion actions-workflow-policy %} and reusable workflows{% endif %}: +When you choose {% data reusables.actions.policy-label-for-select-actions-workflows %}, local actions (`./` and `$/`){% ifversion actions-workflow-policy %} and reusable workflows{% endif %} are allowed, and there are additional options for allowing other specific actions{% ifversion actions-workflow-policy %} and reusable workflows{% endif %}: {% data reusables.repositories.settings-permissions-org-policy-note %} diff --git a/data/reusables/actions/reusable-workflow-calling-syntax.md b/data/reusables/actions/reusable-workflow-calling-syntax.md index 15dc11ce6f60..0a011c97459f 100644 --- a/data/reusables/actions/reusable-workflow-calling-syntax.md +++ b/data/reusables/actions/reusable-workflow-calling-syntax.md @@ -1,6 +1,7 @@ +* `$/.github/workflows/{filename}` for a reusable workflow in the same repository. This is the recommended syntax for referencing a reusable workflow in the same repository. This syntax is not available in {% data variables.product.prodname_ghe_server %}. * `{owner}/{repo}/.github/workflows/{filename}@{ref}` for reusable workflows in {% ifversion fpt %}public and private{% elsif ghec or ghes %}public, internal and private{% endif %} repositories. * `./.github/workflows/{filename}` for reusable workflows in the same repository. -In the first option, `{ref}` can be a SHA, a release tag, or a branch name. If a release tag and a branch have the same name, the release tag takes precedence over the branch name. Using the commit SHA is the safest option for stability and security. For more information, see [AUTOTITLE](/actions/reference/security/secure-use#reusing-third-party-workflows). +When you reference a reusable workflow with `{owner}/{repo}` and `@{ref}`, the `{ref}` can be a SHA, a release tag, or a branch name. If a release tag and a branch have the same name, the release tag takes precedence over the branch name. Using the commit SHA is the safest option for stability and security. For more information, see [AUTOTITLE](/actions/reference/security/secure-use#reusing-third-party-workflows). -If you use the second syntax option (without `{owner}/{repo}` and `@{ref}`) the called workflow is from the same commit as the caller workflow. Ref prefixes such as `refs/heads` and `refs/tags` are not allowed. You cannot use contexts or expressions in this keyword. +When you reference a reusable workflow in the same repository using `$/` or `./` (without `{owner}/{repo}` and `@{ref}`), the called workflow is from the same commit as the caller workflow. A `$/` reference must not include an `@{ref}` suffix, and `$/` is not available in {% data variables.product.prodname_ghe_server %}. Ref prefixes such as `refs/heads` and `refs/tags` are not allowed. You cannot use contexts or expressions in this keyword. diff --git a/data/reusables/actions/uses-keyword-example.md b/data/reusables/actions/uses-keyword-example.md index 342f6f61aec8..db2f521db520 100644 --- a/data/reusables/actions/uses-keyword-example.md +++ b/data/reusables/actions/uses-keyword-example.md @@ -4,6 +4,9 @@ jobs: uses: octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89 call-workflow-2-in-local-repo: uses: ./.github/workflows/workflow-2.yml + # The `$/` syntax is not available in GitHub Enterprise Server. + call-workflow-in-same-repo-at-running-commit: + uses: $/.github/workflows/workflow-2.yml call-workflow-in-another-repo: uses: octo-org/another-repo/.github/workflows/workflow.yml@v1 ``` diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 70514a4233fb..114e37a198ea 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -52,6 +52,58 @@ test('use sidebar to go to Hello World page', async ({ page }) => { await expect(page).toHaveTitle(/Hello World - GitHub Docs/) }) +test('sidebar highlights the clicked item optimistically while navigation is pending', async ({ + page, +}) => { + // Article pages are getServerSideProps routes, so router.asPath (and thus the real + // aria-current) only updates after the destination loads. The sidebar marks the + // clicked link with a visual-only `data-pending` accent so the click is acknowledged + // immediately. Throttle the client-side data fetch so the navigation stays pending + // long enough to observe that intermediate state. + await page.goto('/get-started') + await page.getByTestId('product-sidebar').getByText('Start your journey').click() + + const sidebar = page.getByTestId('product-sidebar') + const helloWorld = sidebar.getByRole('link', { name: 'Hello World' }) + const linkRewriting = sidebar.getByRole('link', { name: 'Link rewriting' }) + + // Hold the next data request open until we release it, so navigation stays pending. + let releaseNavigation = () => {} + const navigationHeld = new Promise((resolve) => { + releaseNavigation = resolve + }) + await page.route('**/_next/data/**', async (route) => { + await navigationHeld + await route.continue() + }) + + await helloWorld.click() + + // While pending: the clicked link carries the optimistic visual marker, but the URL + // and the semantic aria-current still reflect the (still-loaded) get-started page. + await expect(helloWorld).toHaveAttribute('data-pending', '') + await expect(helloWorld).not.toHaveAttribute('aria-current', 'page') + await expect(page).not.toHaveURL(/hello-world/) + + // Let the navigation finish: the marker gives way to a real aria-current. + releaseNavigation() + await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/) + await expect(helloWorld).toHaveAttribute('aria-current', 'page') + await expect(helloWorld).not.toHaveAttribute('data-pending', '') + + // A modifier-click (open in new tab) must NOT move the optimistic selection. + // handleNavClick bails on modifier clicks, so pendingHref is never set: the current + // page keeps its URL, its aria-current, and the clicked link gets no data-pending. + // Use ControlOrMeta so the real "open in new tab" modifier is sent per-platform + // (Ctrl on Linux/Windows CI, Meta on macOS). The click opens a background tab we + // don't need to assert on; catch any popup so it doesn't leak. + page.on('popup', (popup) => popup.close()) + await linkRewriting.click({ modifiers: ['ControlOrMeta'] }) + await expect(linkRewriting).not.toHaveAttribute('data-pending', '') + await expect(helloWorld).toHaveAttribute('aria-current', 'page') + await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/) +}) + test('press "/" to open the search overlay', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) diff --git a/src/landings/components/SidebarProduct.module.scss b/src/landings/components/SidebarProduct.module.scss index 08fe6011cf02..b4d073687e20 100644 --- a/src/landings/components/SidebarProduct.module.scss +++ b/src/landings/components/SidebarProduct.module.scss @@ -7,10 +7,15 @@ // so a top-level article like "Quickstart" gets only the subtle background pill // and no green bar. Restore the bar for active top-level leaves to match nested // items. Brand's classes are hashed, so match on the stable substrings. + // + // This also covers `[data-pending]` — the optimistic marker on a just-clicked item + // (see the pending rules below) — so a clicked top-level leaf gets the bar too. :global([class*="NavList__item--leaf"][class*="NavList__item--level-1"]) :global([class*="NavList__link"])[aria-current]:not( [aria-current="false"] - )::before { + )::before, + :global([class*="NavList__item--leaf"][class*="NavList__item--level-1"]) + :global([class*="NavList__link"])[data-pending]::before { content: ""; position: absolute; // Level-1 links are shorter (~24px) than nested leaves, so use a small inset @@ -25,4 +30,38 @@ border-radius: var(--base-size-2); background-color: var(--brand-NavList-activeIndicator-color); } + + // Optimistic "pending" highlight. Article pages are getServerSideProps routes and can + // be slow to load, so router.asPath — and thus aria-current — only updates once the + // page has loaded. We keep aria-current on the *loaded* page (assistive tech must not + // be told a still-loading destination is the current page), and instead mark the + // just-clicked link with `data-pending` for a VISUAL-ONLY accent. Brand couples its + // active styling to `[aria-current]`, so mirror that treatment here for `[data-pending]`. + // data-pending is only set while a *different* page is loading, so it never collides + // with the real aria-current item. + :global([class*="NavList__link"])[data-pending] { + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-subtle); + } + + // Nested leaves: brand moves the background pill onto the label and adds a leading + // accent bar (mirrors `.item--leaf:not(.item--level-1) .link[aria-current]`). + :global([class*="NavList__item--leaf"]:not([class*="NavList__item--level-1"])) + :global([class*="NavList__link"])[data-pending] { + background-color: transparent; + + :global([class*="NavList__labelArea"]) { + background-color: var(--brand-color-canvas-subtle); + } + + &::before { + content: ""; + position: absolute; + inset-block: var(--base-size-8); + inset-inline-start: 0; + width: var(--base-size-4); + border-radius: var(--base-size-2); + background-color: var(--brand-NavList-activeIndicator-color); + } + } } diff --git a/src/landings/components/SidebarProduct.tsx b/src/landings/components/SidebarProduct.tsx index aef9775db617..233317f16983 100644 --- a/src/landings/components/SidebarProduct.tsx +++ b/src/landings/components/SidebarProduct.tsx @@ -27,7 +27,9 @@ type Router = ReturnType // not next/link), so intercept clicks to restore next/link-style client-side // navigation. Modifier/middle clicks fall through to the browser so open-in-new-tab // still works, and the keeps links crawlable for SSR. Mirrors Breadcrumbs.tsx. -function handleNavClick(router: Router, event: MouseEvent, href: string) { +// Returns true when it performed a client-side navigation (so the caller can move the +// optimistic selection), false when the click was left to the browser. +function handleNavClick(router: Router, event: MouseEvent, href: string): boolean { if ( event.defaultPrevented || event.button !== 0 || @@ -37,12 +39,13 @@ function handleNavClick(router: Router, event: MouseEvent, href: st event.altKey || !href.startsWith('/') ) { - return + return false } event.preventDefault() // hrefs already include the locale prefix (e.g. /en/...), so disable Next.js // locale handling to avoid double-prefixing. router.push(href, undefined, { locale: false }) + return true } // The sidebar renders the full product tree (hundreds of nodes) and fully remounts @@ -50,7 +53,14 @@ function handleNavClick(router: Router, event: MouseEvent, href: st // subscribe to the router ONCE here and hand items a stable routePath plus stable // navigate/prefetch callbacks, instead of every item calling useRouter itself. type SidebarNavValue = { + // The real loaded route. Drives aria-current (the semantic "current page") and the + // auto-expanded active ancestor chain — both must reflect the page actually loaded. routePath: string + // The in-flight click target, or null. Drives a VISUAL-ONLY optimistic accent bar + // (via data-pending) so the click feels acknowledged before the slow + // getServerSideProps page loads — without lying to assistive tech about the current + // page. Once navigation completes, the keyed remount clears it and routePath catches up. + pendingHref: string | null navigate: (event: MouseEvent, href: string) => void prefetch: (href: string) => void } @@ -64,6 +74,17 @@ function useSidebarNav(): SidebarNavValue { return value } +// Props for a leaf link's : aria-current tracks the loaded page (semantics), while +// data-pending marks the in-flight click so CSS can move the accent bar optimistically +// without changing what screen readers announce as current. data-pending is only set +// while a *different* page is loading, so it never double-marks the already-current item. +function leafLinkProps(nav: SidebarNavValue, href: string) { + return { + 'aria-current': (nav.routePath === href ? 'page' : false) as 'page' | false, + 'data-pending': nav.pendingHref === href && nav.routePath !== href ? '' : undefined, + } +} + // Separate context for the REST-only scroll-spy state (full asPath with query+hash, // and query). Kept out of SidebarNavValue so its per-navigation identity churn // doesn't invalidate the memoized common items — only RestNavListItem consumes it. @@ -103,19 +124,43 @@ export const SidebarProduct = () => { const { asPath, locale, query } = router const routePath = `/${locale}${asPath.split('?')[0].split('#')[0]}` + // Optimistic selection: the href of an in-flight click. Used to move the accent bar + // visually (data-pending) the instant a link is clicked, even while the destination + // page is still loading. This SidebarProduct instance persists during the pending + // fetch (SidebarNav keys it on asPath, which only changes once navigation completes), + // so the state survives the wait and is discarded by the keyed remount when the new + // route lands. aria-current is NOT derived from this — it stays on the loaded route. + const [pendingHref, setPendingHref] = useState(null) + const prefetchHref = usePrefetchOnInteraction() // Stable callbacks so memoized items don't re-render on unrelated changes. const navigate = useCallback( - (event: MouseEvent, href: string) => handleNavClick(router, event, href), + (event: MouseEvent, href: string) => { + // Only move the optimistic highlight on a real client-side nav, not on a + // modifier/middle click that opens a new tab (the current page stays put). + if (handleNavClick(router, event, href)) setPendingHref(href) + }, [router], ) const prefetch = useCallback((href: string) => prefetchHref(router, href), [router, prefetchHref]) const navValue = useMemo( - () => ({ routePath, navigate, prefetch }), - [routePath, navigate, prefetch], + () => ({ routePath, pendingHref, navigate, prefetch }), + [routePath, pendingHref, navigate, prefetch], ) const restNavValue = useMemo(() => ({ asPath, query }), [asPath, query]) + useEffect(() => { + // Clear the optimistic highlight if a navigation genuinely fails, so it doesn't + // stick on a page that never loaded. Skip cancellations (err.cancelled) — those + // fire when a second click supersedes the first, and pendingHref already points at + // that newer target, which we want to keep highlighted. + const clearPending = (err: { cancelled?: boolean }) => { + if (!err?.cancelled) setPendingHref(null) + } + router.events.on('routeChangeError', clearPending) + return () => router.events.off('routeChangeError', clearPending) + }, [router.events]) + useEffect(() => { // Brand NavList auto-expands the whole ancestor chain of the active item, so // scroll to the item marked aria-current="page" (the active article) rather @@ -243,14 +288,14 @@ function navListLevelSentinel() { } const LeafLink = memo(function LeafLink({ node }: { node: ProductTreeNode }) { - const { routePath, navigate, prefetch } = useSidebarNav() + const nav = useSidebarNav() return ( ) => navigate(event, node.href)} - {...prefetchHandlers(prefetch, node.href)} + {...leafLinkProps(nav, node.href)} + onClick={(event: MouseEvent) => nav.navigate(event, node.href)} + {...prefetchHandlers(nav.prefetch, node.href)} > {node.title} @@ -264,9 +309,9 @@ const NavListItem = memo(function NavListItem({ childPage: ProductTreeNode level?: number }) { - const { routePath, navigate, prefetch } = useSidebarNav() + const nav = useSidebarNav() + const { routePath, navigate, prefetch } = nav const locale = routePath.split('/')[1] - const isActive = routePath === childPage.href const hasChildren = childPage.childPages.length > 0 const specialCategory = childPage.layout === 'category-landing' const canNest = level < MAX_NAVLIST_LEVEL @@ -307,7 +352,7 @@ const NavListItem = memo(function NavListItem({ ) => navigate(event, sidebarLinkHref)} {...prefetchHandlers(prefetch, sidebarLinkHref)} > @@ -318,7 +363,7 @@ const NavListItem = memo(function NavListItem({ ) => navigate(event, childPage.href)} {...prefetchHandlers(prefetch, childPage.href)} > @@ -333,7 +378,8 @@ const NavListItem = memo(function NavListItem({ }) function RestNavListItem({ category }: { category: ProductTreeNode }) { - const { routePath, navigate, prefetch } = useSidebarNav() + const nav = useSidebarNav() + const { routePath, navigate, prefetch } = nav const { asPath, query } = useRestNav() const [visibleAnchor, setVisibleAnchor] = useState('') const miniTocItems = @@ -378,7 +424,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { ) => navigate(event, category.href)} {...prefetchHandlers(prefetch, category.href)} > @@ -429,7 +475,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { key={childPage.href} as="a" href={childPage.href} - aria-current={routePath === childPage.href ? 'page' : false} + {...leafLinkProps(nav, childPage.href)} onClick={(event: MouseEvent) => navigate(event, childPage.href)} {...prefetchHandlers(prefetch, childPage.href)} >